优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)

This commit is contained in:
2026-06-16 02:33:16 +08:00
parent d2cb38cdac
commit 10e4945e5a
26 changed files with 2337 additions and 332 deletions

View File

@@ -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 {