优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)
This commit is contained in:
@@ -37,12 +37,18 @@
|
||||
</div>
|
||||
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
|
||||
<div class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve" @click="emit('approve', { id: tc.id, approved: true })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove(true)">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject" @click="emit('approve', { id: tc.id, approved: false })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onApprove(false)">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiTool.reject') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,11 +107,23 @@
|
||||
<code>{{ formatToolResult(tc) }}</code>
|
||||
</div>
|
||||
</div><!-- /.ai-tool-body -->
|
||||
|
||||
<!-- AE-2025-05:High 风险审批二次确认(本卡自治 useConfirm,不与 AiChat 父级 confirmState 串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
// composable 外全局 i18n 实例(非 setup 上下文,供纯函数 argLabel/formatToolResult 使用)
|
||||
const t = ((i18n as any).global.t as (k: string, p?: Record<string, unknown>) => string).bind((i18n as any).global)
|
||||
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
interface ToolResult {
|
||||
@@ -148,19 +166,22 @@ function formatJson(data: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批参数 key → 友好标签映射(静态,不查 store);未命中回退原 key */
|
||||
const ARG_LABEL_MAP: Record<string, string> = {
|
||||
id: 'ID',
|
||||
project_id: '项目 ID',
|
||||
name: '名称',
|
||||
title: '标题',
|
||||
description: '描述',
|
||||
field: '字段',
|
||||
value: '新值',
|
||||
path: '路径',
|
||||
priority: '优先级',
|
||||
tags: '标签',
|
||||
source: '来源',
|
||||
/** 审批参数 key → 友好标签映射(动态 i18n);未命中回退原 key */
|
||||
function argLabel(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
id: t('aiTool.argLabel.id'),
|
||||
project_id: t('aiTool.argLabel.projectId'),
|
||||
name: t('aiTool.argLabel.name'),
|
||||
title: t('aiTool.argLabel.title'),
|
||||
description: t('aiTool.argLabel.description'),
|
||||
field: t('aiTool.argLabel.field'),
|
||||
value: t('aiTool.argLabel.value'),
|
||||
path: t('aiTool.argLabel.path'),
|
||||
priority: t('aiTool.argLabel.priority'),
|
||||
tags: t('aiTool.argLabel.tags'),
|
||||
source: t('aiTool.argLabel.source'),
|
||||
}
|
||||
return map[key] || key
|
||||
}
|
||||
|
||||
/** 审批参数转键值对数组(非对象/空时返回空数组,防 args 非 object 时 v-for 报错) */
|
||||
@@ -168,7 +189,7 @@ function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val
|
||||
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
||||
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({
|
||||
key,
|
||||
label: ARG_LABEL_MAP[key] || key,
|
||||
label: argLabel(key),
|
||||
val,
|
||||
}))
|
||||
}
|
||||
@@ -254,7 +275,7 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
: typeof a.name === 'string' ? a.name
|
||||
: typeof a.path === 'string' ? a.path
|
||||
: ''
|
||||
if (detail) return `${raw}(${detail})`
|
||||
if (detail) return t('aiTool.resultDetail', { raw, detail })
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -266,13 +287,19 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// AE-2025-05:本卡自治二次确认状态机。每张 ToolCard 各持一份 confirmState,与 AiChat
|
||||
// 父级 useConfirm 实例互不影响(并发弹层天然隔离,无需共享)。
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 单条工具调用数据 */
|
||||
tc: AiToolCallInfo
|
||||
@@ -291,6 +318,67 @@ const emit = defineEmits<{
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 审批按钮 loading 态(B-260616-08)。
|
||||
* 点击后置 true,禁用两按钮防重复点击并显示 spinner。后端回事件 useAiEvents 把 tc.status
|
||||
* 离开 pending_approval(转 completed/rejected)→ watch 自动复位 false,卡片同步切态。
|
||||
*
|
||||
* 兜底:approve 后状态保持 pending_approval,若后端既不回事件也不转 running(仅在
|
||||
* AiApprovalResult/approved 后 hang 在工具执行前),全局看门狗只回滚 running 不会触及本卡。
|
||||
* 故此处加本地兜底计时(对齐全局看门狗 STREAM_TIMEOUT_MS),到点复位 loading 让用户能重试/改判。
|
||||
*/
|
||||
const APPROVE_LOADING_TIMEOUT_MS = 130000 // 对齐 useAiStream.STREAM_TIMEOUT_MS
|
||||
const approving = ref(false)
|
||||
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐)。
|
||||
* AiToolCallInfo 无 risk 字段(风险仅存后端),故前端按工具名判定。新增 High 工具时同步加这里。
|
||||
* - delete 类(delete_project/purge_project/delete_task/delete_file/restore_project)→ 删除/不可恢复文案
|
||||
* - run_command → 执行命令文案
|
||||
* - 其余 High(未来扩展) → 通用高危文案
|
||||
*/
|
||||
const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
||||
])
|
||||
|
||||
/** High 风险工具的二次确认文案(按操作类型) */
|
||||
function highRiskConfirmMsg(name: string): string {
|
||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
||||
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
||||
return t('aiTool.confirmHighDelete')
|
||||
}
|
||||
return t('aiTool.confirmHighGeneric')
|
||||
}
|
||||
|
||||
async function onApprove(approved: boolean) {
|
||||
if (approving.value) return // 防重入
|
||||
// AE-2025-05:High 风险 + 批准 → 置 approving 前先二次确认,误点可取消。
|
||||
// 拒绝(approved=false)不再二次确认(拒绝本就无害,加确认反增摩擦)。
|
||||
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
|
||||
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
|
||||
if (!ok) return
|
||||
}
|
||||
approving.value = true
|
||||
// 本地兜底:到点强制复位 loading(后端无回执、卡片仍 pending_approval 时给用户重试入口)
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
emit('approve', { id: props.tc.id, approved })
|
||||
}
|
||||
// status 离开 pending_approval → 后端已回事件 → 复位 loading 并清计时器
|
||||
watch(() => props.tc.status, (s) => {
|
||||
if (s !== 'pending_approval') {
|
||||
approving.value = false
|
||||
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
|
||||
}
|
||||
})
|
||||
// 组件卸载清计时器防泄漏
|
||||
onBeforeUnmount(() => {
|
||||
if (approvingTimer) clearTimeout(approvingTimer)
|
||||
})
|
||||
|
||||
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
|
||||
@@ -602,6 +690,35 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
color: #fff;
|
||||
border-color: var(--df-danger);
|
||||
}
|
||||
/* 审批 loading(B-260616-08):IPC 送达待后端回执期间禁用两按钮防重复点击,spinner 转在原位 */
|
||||
.ai-tool-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-tool-btn--approve:disabled:hover,
|
||||
.ai-tool-btn--reject:disabled:hover {
|
||||
/* 覆盖 hover 高亮,disabled 态不闪色 */
|
||||
background: var(--df-success-bg);
|
||||
color: var(--df-success);
|
||||
border-color: var(--df-border);
|
||||
}
|
||||
.ai-tool-btn--reject:disabled:hover {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-btn-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
animation: aiToolBtnSpin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes aiToolBtnSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* -- File Content (read_file) -- */
|
||||
.ai-tool-file-content {
|
||||
|
||||
Reference in New Issue
Block a user