diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index b2e17b6..f05df9c 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -132,17 +132,44 @@ const t = ((i18n as any).global.t as (k: string, p?: Record) => /** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */ interface ToolResult { path?: string + from?: string + to?: string content?: string - lines?: number - size?: number + lines?: number | null + size?: number | null bytes_written?: number + new_size?: number + bytes_moved?: number entries?: Array<{ name: string; type: string; size?: number; depth?: number }> name?: string title?: string field?: string + id?: string + status?: string + stack?: string + bound?: boolean deleted?: boolean restored?: boolean purged?: boolean + updated?: boolean + changed?: boolean + renamed?: boolean + exists?: boolean + is_binary?: boolean + is_dir?: boolean + permanent?: boolean + backed_up?: boolean + size_diff?: number + exit_code?: number + duration_ms?: number + stdout?: string + stderr?: string + truncated?: boolean + results?: Array<{ path?: string; size?: number }> + total?: number + has_more?: boolean + warning?: string + review_rounds?: number } /** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */ @@ -267,7 +294,11 @@ function argString(args: unknown, key: string): string { return '' } -/** 通用工具结果格式化(非文件类工具) */ +/** + * 通用工具结果格式化(非文件类工具)。 + * UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。 + * read_file/list_directory/write_file 有模板特化分支,不进此函数。 + */ function formatToolResult(tc: AiToolCallInfo): string { const r = parseResult(tc.result) if (!r) { @@ -283,10 +314,111 @@ function formatToolResult(tc: AiToolCallInfo): string { } return raw } - if (tc.name === 'write_file') { - return `${r.path} (${formatBytes(r.bytes_written)})` + switch (tc.name) { + case 'write_file': + return `${r.path} (${formatBytes(r.bytes_written)})` + case 'update_task': + return r.updated !== false + ? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' }) + : t('aiTool.updated') + case 'advance_task': + return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' }) + case 'delete_task': + return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') + case 'delete_file': + return r.permanent + ? t('aiTool.deleteFilePermanent', { path: r.path || '' }) + : t('aiTool.deleteFileSoft', { path: r.path || '' }) + case 'run_command': + return formatCommandResult(r) + case 'patch_file': + return r.changed === false + ? t('aiTool.patchedNoChange', { path: r.path || '' }) + : t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) }) + case 'append_file': + return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) }) + case 'rename_file': + return r.renamed + ? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' }) + : formatJson(r) + case 'search_files': { + const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0) + return t('aiTool.foundN', { n }) + } + case 'file_info': + return formatFileInfoResult(r) + case 'bind_directory': + return r.bound + ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) + : formatJson(r) + default: + return formatJson(r) } - return formatJson(r) +} + +/** size_diff 数字 → "+N"/"-N"/"+0" 字符串(负数补减号,正数补加号) */ +function formatSizeDiff(diff: number | undefined | null): string { + const n = typeof diff === 'number' ? diff : 0 + return n >= 0 ? `+${n}` : `${n}` +} + +/** + * run_command 结果摘要:exit_code 标题 + stdout/stderr 前 N 行 + 总行数提示(截断,不全量显示)。 + * 输出可能很大(编译/find/cat 大文件),仅显示前若干行 + 总行数提示,防撑爆卡片。 + */ +function formatCommandResult(r: ToolResult): string { + const code = typeof r.exit_code === 'number' ? r.exit_code : -1 + const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0 + const head = code === 0 + ? t('aiTool.commandOk', { ms }) + : t('aiTool.commandFailed', { code }) + // 合并 stdout + stderr(实际 stderr 通常含报错),各自取前若干行 + const MAX_LINES = 20 + const out = combineAndTruncateLines(r.stdout, r.stderr, MAX_LINES) + const parts = [head] + if (out.text) { + parts.push(out.text) + if (out.truncated) { + parts.push(t('aiTool.commandOutputLines', { n: out.total })) + } + } + return parts.join('\n') +} + +/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数 */ +function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } { + const parts: string[] = [] + if (stdout) parts.push(stdout) + if (stderr) parts.push(stderr) + if (!parts.length) return { text: '', truncated: false, total: 0 } + const combined = parts.join('\n') + const allLines = combined.split('\n') + const total = allLines.length + if (total <= maxLines) { + return { text: combined, truncated: false, total } + } + // 取前 N 行 + 省略提示 + return { text: allLines.slice(0, maxLines).join('\n'), truncated: true, total } +} + +/** file_info 结果摘要:存在性 + 大小 + 行数(文本)/二进制/目录 标注 */ +function formatFileInfoResult(r: ToolResult): string { + if (r.exists === false) { + return t('aiTool.fileInfoMissing', { path: r.path || '' }) + } + let base = t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) }) + const extras: string[] = [] + if (r.is_dir) { + extras.push(t('aiTool.fileInfoDir')) + } else { + if (typeof r.lines === 'number') { + extras.push(t('aiTool.fileInfoLines', { n: r.lines })) + } + if (r.is_binary) { + extras.push(t('aiTool.fileInfoBinary')) + } + } + return base + extras.join('') } @@ -469,7 +601,11 @@ function toolDisplayName(tc: AiToolCallInfo): string { } } -/** completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃) */ +/** + * completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。 + * UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致); + * read_file/list_directory/write_file 有模板特化分支,header 摘要仍显示关键计数。 + */ function toolResultSummary(tc: AiToolCallInfo): string { if (tc.status !== 'completed') return '' const r = parseResult(tc.result) @@ -482,9 +618,39 @@ function toolResultSummary(tc: AiToolCallInfo): string { case 'create_idea': case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created') case 'update_project': return r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated') - case 'delete_project': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') + case 'update_task': return r.updated !== false + ? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' }) + : t('aiTool.updated') + case 'advance_task': return t('aiTool.advancedTask', { id: r.id || '', status: r.status || '' }) + case 'delete_project': + case 'delete_task': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') + case 'delete_file': return r.permanent + ? t('aiTool.deleteFilePermanent', { path: r.path || '' }) + : t('aiTool.deleteFileSoft', { path: r.path || '' }) case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective') case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective') + case 'run_command': { + const code = typeof r.exit_code === 'number' ? r.exit_code : -1 + const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0 + return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code }) + } + case 'patch_file': return r.changed === false + ? t('aiTool.patchedNoChange', { path: r.path || '' }) + : t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) }) + case 'append_file': return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) }) + case 'rename_file': return r.renamed + ? t('aiTool.renamedFile', { from: r.from || '', to: r.to || '' }) + : '' + case 'search_files': { + const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0) + return t('aiTool.foundN', { n }) + } + case 'file_info': return r.exists === false + ? t('aiTool.fileInfoMissing', { path: r.path || '' }) + : t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) }) + case 'bind_directory': return r.bound + ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) + : '' case 'run_workflow': return t('aiTool.workflowHint') default: return '' } diff --git a/src/i18n/en/aiTool.ts b/src/i18n/en/aiTool.ts index ed416cc..f78bb7d 100644 --- a/src/i18n/en/aiTool.ts +++ b/src/i18n/en/aiTool.ts @@ -48,6 +48,26 @@ export default { purgeIneffective: 'Purge ineffective', workflowHint: 'Run it on the workflow page', + // UX-260616-04: formatToolResult covers all tools with human-readable summaries (missing keys) + updatedTaskField: 'Task {id} updated field {field}', + advancedTask: 'Task {id} advanced to {status}', + commandOk: 'Command succeeded ({ms} ms)', + commandFailed: 'Command failed (exit code {code})', + commandOutputLines: '{n} lines of output', + patchedFile: 'Patched {path} ({diff} bytes)', + patchedNoChange: '{path} no change', + appendedTo: 'Appended {bytes} to {path}', + renamedFile: 'Renamed {from} → {to}', + foundN: 'Found {n}', + boundDir: 'Bound directory "{path}" stack: {stack}', + deleteFilePermanent: 'Permanently deleted {path}', + deleteFileSoft: 'Soft-deleted {path} (backed up)', + fileInfoSize: '{path} {size}', + fileInfoLines: ' · {n} lines', + fileInfoBinary: ' · binary', + fileInfoDir: ' · directory', + fileInfoMissing: '{path} does not exist', + projectLabel: '"{name}"', projectIdNotFound: '(project not found, id={id})', diff --git a/src/i18n/zh-CN/aiTool.ts b/src/i18n/zh-CN/aiTool.ts index 8da42e0..1126a8f 100644 --- a/src/i18n/zh-CN/aiTool.ts +++ b/src/i18n/zh-CN/aiTool.ts @@ -50,6 +50,26 @@ export default { purgeIneffective: '清除未生效', workflowHint: '请到工作流页面运行', + // UX-260616-04:formatToolResult 覆盖全部工具产出人类可读摘要(补缺失 key) + updatedTaskField: '任务 {id} 已更新字段 {field}', + advancedTask: '任务 {id} 已推进至 {status}', + commandOk: '命令成功({ms} 毫秒)', + commandFailed: '命令失败(退出码 {code})', + commandOutputLines: '{n} 行输出', + patchedFile: '已修改 {path}(增减 {diff} 字节)', + patchedNoChange: '{path} 无更改', + appendedTo: '已向 {path} 追加 {bytes}', + renamedFile: '已重命名 {from} → {to}', + foundN: '找到 {n} 项', + boundDir: '已绑定目录「{path}」技术栈:{stack}', + deleteFilePermanent: '已硬删除 {path}', + deleteFileSoft: '已软删除 {path}(已备份)', + fileInfoSize: '{path} {size}', + fileInfoLines: ' · {n} 行', + fileInfoBinary: ' · 二进制', + fileInfoDir: ' · 目录', + fileInfoMissing: '{path} 不存在', + // 审批参数特化(id/project_id 回显项目名,查不到标注原 id) projectLabel: '「{name}」', projectIdNotFound: '(项目已不存在, id={id})',