diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index ce03184..312120a 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -136,6 +136,7 @@ function shouldKeepOpen(tc: AiToolCallInfo): boolean { } function formatToolName(name: string): string { + if (!name) return '' return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } @@ -200,6 +201,7 @@ function parseResult(result: unknown): ToolResult | null { } function toolCategory(name: string): string { + if (!name) return 'default' if (name === 'read_file') return 'file-read' if (name === 'write_file') return 'file-write' if (name === 'list_directory') return 'dir' @@ -223,14 +225,23 @@ function shortPath(p: string): string { return parts.length <= 2 ? p : '.../' + parts.slice(-2).join('/') } -/** 格式化字节数 */ -function formatBytes(bytes: number | undefined): string { - if (!bytes) return '0 B' +/** 格式化字节数(兜底:undefined/null/负/NaN/Infinity 统一降级为 '0 B',防渲染异常) */ +function formatBytes(bytes: number | undefined | null): string { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B' if (bytes < 1024) return bytes + ' B' if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB' return (bytes / 1048576).toFixed(1) + ' MB' } +/** 从工具 args(unknown)中安全取字符串字段;非对象/字段非 string 时返回空串 */ +function argString(args: unknown, key: string): string { + if (args && typeof args === 'object' && !Array.isArray(args)) { + const v = (args as Record)[key] + return typeof v === 'string' ? v : '' + } + return '' +} + /** 通用工具结果格式化(非文件类工具) */ function formatToolResult(tc: AiToolCallInfo): string { const r = parseResult(tc.result) @@ -238,8 +249,11 @@ function formatToolResult(tc: AiToolCallInfo): string { // 非 JSON(纯文本:审批占位/错误/拒绝)→ 原样显示,审批占位时补拟执行参数避免空白 const raw = typeof tc.result === 'string' ? tc.result : '' if (raw && tc.args && typeof tc.args === 'object') { - const a = tc.args as any - const detail = a.title || a.name || a.path || '' + const a = tc.args as Record + const detail = typeof a.title === 'string' ? a.title + : typeof a.name === 'string' ? a.name + : typeof a.path === 'string' ? a.path + : '' if (detail) return `${raw}(${detail})` } return raw @@ -325,8 +339,7 @@ function displayArgValue(arg: { key: string; val: unknown }): string { * 解析项目名(复用 projectNameById,无则走 fallback),create_* 用固定文案。 */ function toolDisplayName(tc: AiToolCallInfo): string { - const args = tc.args as any - const p = args?.path as string | undefined + const p = argString(tc.args, 'path') switch (tc.name) { case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback') case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback') @@ -336,7 +349,7 @@ function toolDisplayName(tc: AiToolCallInfo): string { case 'purge_project': case 'update_project': { const idKey = PROJECT_ID_TOOL_ARG[tc.name] - const id = idKey ? (args?.[idKey] as string | undefined) : '' + const id = idKey ? argString(tc.args, idKey) : '' const name = id ? projectNameById.value[id] : '' const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix' const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback' diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index a727bea..538362c 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -74,15 +74,27 @@ export async function switchConversation(id: string) { content: m.content || '', model: m.model, timestamp: Date.now(), - toolCalls: m.tool_calls?.map((tc: any) => ({ - id: tc.id, - name: tc.function?.name || '', - args: typeof tc.function?.arguments === 'string' - ? JSON.parse(tc.function.arguments || '{}') - : tc.function?.arguments || {}, - status: 'completed' as const, - result: toolResultMap.get(tc.id), - })), + toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => { + // 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填 + let args: unknown = {} + const rawArgs = tc.function?.arguments + if (typeof rawArgs === 'string') { + try { + args = rawArgs ? JSON.parse(rawArgs) : {} + } catch { + args = {} + } + } else if (rawArgs && typeof rawArgs === 'object') { + args = rawArgs + } + return { + id: tc.id, + name: tc.function?.name || '', + args, + status: 'completed' as const, + result: toolResultMap.get(tc.id), + } + }), })) } catch { state.messages = [] diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 91d298c..753eec4 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -127,6 +127,10 @@ export function handleEvent(event: AiChatEvent) { break } + case 'AiHeartbeat': + // 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透 + break + case 'AiToolCallStarted': { const info: AiToolCallInfo = { id: event.id, diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index 6d3507f..55a6b7e 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -13,7 +13,7 @@ import { aiApi } from '../../api' import { useAppSettingsStore } from '../../stores/appSettings' import { state } from '../../stores/ai' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' -import { startListener } from './useAiEvents' +import { startListener, findToolCall } from './useAiEvents' import { nextMsgId } from './aiShared' const appSettings = useAppSettingsStore() @@ -81,9 +81,8 @@ async function sendMessage(text: string, skill?: string) { /** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */ async function approveToolCall(toolCallId: string, approved: boolean) { // 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected) - const tc = state.messages - .flatMap(m => m.toolCalls || []) - .find(t => t.id === toolCallId) + // 复用 findToolCall(反向扫描)避免重复实现查找逻辑 + const tc = findToolCall(toolCallId) if (tc) tc.status = 'running' // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear, // 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。 diff --git a/src/stores/project.ts b/src/stores/project.ts index 065b8b6..f6f0342 100644 --- a/src/stores/project.ts +++ b/src/stores/project.ts @@ -248,16 +248,25 @@ function createStore() { /** * 发送审批响应。 - * - 单选(select_type=single 或缺省):传 decision 单值,decisions 留空; - * - 多选(select_type=multiple):传 decisions 数组。 - * F-260615-01: 新增 decisions/select_type 透传,与后端 IPC 签名对齐。 + * + * 统一以 decisions 数组表达选择,按 pendingApproval.select_type 判定单/多选: + * - 单选(select_type='single' 或缺省):取 decisions[0] 作为后端 decision 单值; + * - 多选(select_type='multiple'):decisions 透传全量。 + * + * 后端 IPC 需同时收 decision(单值,向后兼容)与 decisions(数组),此处按 select_type + * 自动分派,消除调用方"该传 decision 还是 decisions"的歧义。 + * F-260615-01: decisions/select_type 透传,与后端 IPC 签名对齐。 */ async function approveHumanApproval( - decision: string, + decisions: string[], comment?: string, - decisions?: string[], ) { if (!state.pendingApproval) return + const selectType = state.pendingApproval.select_type ?? 'single' + // 单选取首项(后端 decision 为单值);多选 decision 取首项作占位(后端按 decisions 走) + const decision = selectType === 'multiple' + ? (decisions[0] ?? '') + : (decisions[0] ?? '') try { // 调用 IPC 命令发送审批响应 @@ -269,8 +278,8 @@ function createStore() { decision, comment, options: state.pendingApproval.options ?? [], - decisions: decisions ?? [], - select_type: state.pendingApproval.select_type ?? 'single', // B-260615-34:Tauri 2 IPC 不转 camelCase,须 snake_case 对齐后端 workflow.rs:211(同 invoke 其余字段 execution_id/node_id/decisions 均已 snake_case) + decisions, + select_type: selectType, // B-260615-34:Tauri 2 IPC 不转 camelCase,须 snake_case 对齐后端 workflow.rs:211 }) // 清除待审批状态 diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index 1288cbb..96b7d6a 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -407,7 +407,7 @@ async function handleImportDir() { // ── 审批处理 ── async function handleApproval(decision: string) { - await store.approveHumanApproval(decision) + await store.approveHumanApproval([decision]) showApprovalDialog.value = false } @@ -417,8 +417,8 @@ const multiDecisions = ref([]) async function handleApprovalMulti() { if (multiDecisions.value.length === 0) return - // decision 传首项(向后兼容),decisions 透传全量 - await store.approveHumanApproval(multiDecisions.value[0], undefined, [...multiDecisions.value]) + // 统一传 decisions 数组,store 按 select_type 分派单/多选 + await store.approveHumanApproval([...multiDecisions.value]) multiDecisions.value = [] showApprovalDialog.value = false }