重构: 拆ToolCard.vue前端(strategy·composable抽离)

- 新建 composables/ai/useToolCard.ts(502行): 纯函数/常量/类型/工具元数据/审批参数/结果格式化(formatToolResult/toolResultSummary/combineOutputs/parseResult等20+)
- ToolCard.vue 1527→1066行(-461): script模块级块删+import composable, 模板/style零改动
- 留ToolCard.vue: 耦合projectStore的computed/审批watch(非纯函数)
i18n等价: toolResultSummary原useI18n.t→composable i18n.global.t(单根同一实例)
主代兜底: vue-tsc --noEmit 0 + grep useToolCard抽离/ToolCard import印证
strategy: 单批1-2文件原子, 模板不变逻辑抽离
This commit is contained in:
2026-06-19 03:44:16 +08:00
parent 96a05638b1
commit 6a75c11dfa
3 changed files with 529 additions and 482 deletions

View File

@@ -2230,6 +2230,12 @@ onMounted(async () => {
await store.loadProviders() await store.loadProviders()
await store.loadConversations() await store.loadConversations()
store.loadSkills() store.loadSkills()
// @ 提及预加载: AiChat 的 @ 联想读取 projectsStore.projects/tasks,
// 但 loadProjects/loadTasks 分别绑在 Projects.vue/Tasks.vue 的 onMounted。
// 若用户启动后直接用 AI Chat(未访问过这两个页面), store 数据为空 → @ 无匹配。
// 此处按需预加载(已加载则跳过), 不阻塞首屏(无 await, 后台执行)。
if (!projectsStore.projects.length) void projectsStore.loadProjects()
if (!projectsStore.tasks.length) void projectsStore.loadTasks()
// B-260616-12: 监听工具慢执行提示(useAiEvents 经 Tauri 事件总线广播;composable 无组件上下文, // B-260616-12: 监听工具慢执行提示(useAiEvents 经 Tauri 事件总线广播;composable 无组件上下文,
// 由本组件弹本地 toast。主窗口与分离窗口各挂一份 AiChat,各自消费,无重复——emit 单发, // 由本组件弹本地 toast。主窗口与分离窗口各挂一份 AiChat,各自消费,无重复——emit 单发,
// 两窗口的 listener 各收一次但 msg 相同,toast 各自渲染互不干扰) // 两窗口的 listener 各收一次但 msg 相同,toast 各自渲染互不干扰)

View File

@@ -191,416 +191,33 @@
</div> </div>
</template> </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 {
path?: string
pattern?: string
from?: string
to?: string
content?: string
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
// UX-260618-05~12:补全后端返回前端消费的字段(review_rounds/truncated/bytes_moved/has_more/total/entries 已存在)
task_id?: string
execution_id?: string
target_status?: string
note?: string
diff?: string
matches_found?: number
backup_path?: string
returned_lines?: number
items?: Array<Record<string, unknown>>
modified?: number | null
old_size?: number | null
encoding?: string
cross_volume?: boolean
}
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
const dirIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
const fileIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
function shouldKeepOpen(tc: AiToolCallInfo): boolean {
return tc.status === 'running'
|| tc.status === 'pending_approval'
|| tc.status === 'rejected'
|| tc.name === 'write_file'
}
/**
* UX-260616-01: 工具执行失败检测(completed 状态下的失败语义)。
*
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
* 此函数区分两类失败:
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
* - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
*
* 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。
* running/pending_approval 自然 false(无 result 或 result 未稳定)。
*/
function isToolFailure(tc: AiToolCallInfo): boolean {
if (tc.status !== 'completed') return false
// run_command:结构化 exit_code 判定(可靠)
if (tc.name === 'run_command') {
const r = parseResult(tc.result)
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
// run_command 但无 exit_code(异常)→ 保守判失败
if (r) return true
}
// 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀
if (!parseResult(tc.result)) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
// 误伤 search_files 命中 error.log / run_command stderr 含 "warning" 等正常产出)。
// 仅当某行以「执行失败」(中文 err_msg 前缀)/「Error:」/「Failed:」开头才判失败。
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
}
function formatToolName(name: string): string {
if (!name) return ''
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
/** run_command 命令首行截断(头部展示;多行命令取首行,超长省略) */
function shortCmd(cmd: string, max = 48): string {
const firstLine = cmd.split('\n')[0].replace(/\s+/g, ' ').trim()
return firstLine.length > max ? firstLine.slice(0, max) + '…' : firstLine
}
function formatJson(data: unknown): string {
try {
return JSON.stringify(data, null, 2)
} catch {
return String(data)
}
}
/** 审批参数 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 报错) */
function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val: unknown }> {
if (args && typeof args === 'object' && !Array.isArray(args)) {
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({
key,
label: argLabel(key),
val,
}))
}
return []
}
/** 格式化单个审批参数值:字符串直接展示(超长截断),对象/数组 JSON 化后截断 */
function formatArgValue(val: unknown): string {
if (val == null) return ''
if (typeof val === 'string') return val.length > 300 ? val.slice(0, 300) + '…' : val
if (typeof val === 'object') {
try {
const s = JSON.stringify(val)
return s.length > 300 ? s.slice(0, 300) + '…' : s
} catch {
return String(val)
}
}
return String(val)
}
/** 安全解析工具结果 JSON;list_* 运行时是数组,类型归 ToolResult 靠 Array.isArray 区分 */
function parseResult(result: unknown): ToolResult | null {
if (!result) return null
try {
return (typeof result === 'string' ? JSON.parse(result) : result) as ToolResult
} catch {
return 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'
if (name.includes('create')) return 'create'
if (name.includes('delete')) return 'delete'
if (name.includes('list')) return 'list'
return 'default'
}
function toolIcon(name: string): string {
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'list_directory') return dirIcon
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
}
function shortPath(p: string): string {
const parts = p.replace(/\\/g, '/').split('/')
return parts.length <= 2 ? p : '.../' + parts.slice(-2).join('/')
}
/** 格式化字节数(兜底: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<string, unknown>)[key]
return typeof v === 'string' ? v : ''
}
return ''
}
/**
* 通用工具结果格式化(非文件类工具)。
* UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。
* read_file/list_directory/write_file 有模板特化分支,不进此函数。
*/
function formatToolResult(tc: AiToolCallInfo): string {
const r = parseResult(tc.result)
if (!r) {
// 非 JSON(纯文本:审批占位/错误/拒绝)→ 原样显示,审批占位时补拟执行参数避免空白
const raw = typeof tc.result === 'string' ? tc.result : ''
if (raw && tc.args && typeof tc.args === 'object') {
const a = tc.args as Record<string, unknown>
const detail = typeof a.title === 'string' ? a.title
: typeof a.name === 'string' ? a.name
: typeof a.path === 'string' ? a.path
: ''
if (detail) return t('aiTool.resultDetail', { raw, detail })
}
return raw
}
switch (tc.name) {
case 'list_projects':
case 'list_tasks':
case 'list_ideas':
case 'list_trash': {
// UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组),渲染结构化列表替代裸 JSON
const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : [])
const total = typeof r.total === 'number' ? r.total : items.length
const head = tc.name === 'list_projects' ? t('aiTool.projectCount', { n: total })
: tc.name === 'list_tasks' ? t('aiTool.taskCount', { n: total })
: tc.name === 'list_ideas' ? t('aiTool.ideaCount', { n: total })
: t('aiTool.trashCount', { n: total })
const lines = items.slice(0, 10).map((it: Record<string, unknown>) => {
const label = typeof it.name === 'string' ? it.name
: typeof it.title === 'string' ? it.title
: typeof it.id === 'string' ? it.id
: ''
const status = typeof it.status === 'string' ? ` [${it.status}]` : ''
const stack = typeof it.stack === 'string' ? ` (${it.stack})` : ''
return label ? `- ${label}${status}${stack}` : ''
}).filter(Boolean)
const parts = [head, ...lines]
if (r.has_more) parts.push(t('aiTool.listHasMore', { n: items.length }))
return parts.join('\n')
}
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':
// UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
// UX-260618-14: 后端返 TaskRecord(含 title),优先显 title,无则回退 id(防裸 UUID)
return typeof r.review_rounds === 'number' && r.review_rounds > 0
? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { title: r.title || 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 || '', backup_path: r.backup_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
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: 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)
case 'run_workflow': {
// UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
if (!execId) return formatJson(r)
return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId })
}
default:
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')
}
/** SW-260618-19: 合并 stdout/stderr(空串跳过),供 cmdOutput 展开 + combineAndTruncateLines 截断共享 DRY */
function combineOutputs(stdout: string | undefined, stderr: string | undefined): string {
const parts: string[] = []
if (stdout) parts.push(stdout)
if (stderr) parts.push(stderr)
return parts.join('\n')
}
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数(合并复用 combineOutputs) */
function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
const combined = combineOutputs(stdout, stderr)
if (!combined) return { text: '', truncated: false, total: 0 }
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[] = []
// UX-260618-12: file_info modified 时间(epoch_ms → 客户端 toLocaleString)
if (typeof r.modified === 'number' && r.modified > 0) {
extras.push(t('aiTool.fileInfoModified', { time: new Date(r.modified).toLocaleString() }))
}
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('')
}
</script>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue' import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream' import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import {
shouldKeepOpen,
isToolFailure,
formatToolName,
shortCmd,
toolArgsEntries,
formatArgValue,
parseResult,
toolCategory,
toolIcon,
shortPath,
formatBytes,
argString,
formatSizeDiff,
combineOutputs,
formatToolResult,
toolResultSummary,
dirIcon,
fileIcon,
} from '@/composables/ai/useToolCard'
import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -885,84 +502,6 @@ function toolDisplayName(tc: AiToolCallInfo): string {
default: return formatToolName(tc.name) default: return formatToolName(tc.name)
} }
} }
/**
* 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)
if (!r) return ''
switch (tc.name) {
case 'list_tasks':
case 'list_projects':
case 'list_ideas':
case 'list_trash': {
// UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数
const n = Array.isArray(r) ? r.length
: (typeof r.total === 'number' ? r.total
: (Array.isArray(r.items) ? r.items.length : 0))
if (!n) return ''
const key = tc.name === 'list_projects' ? 'projectCount'
: tc.name === 'list_tasks' ? 'taskCount'
: tc.name === 'list_ideas' ? 'ideaCount'
: 'trashCount'
return t(`aiTool.${key}`, { n })
}
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
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 'update_task': return r.updated !== false
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated')
case 'advance_task': return typeof r.review_rounds === 'number' && r.review_rounds > 0
// UX-260618-14: 优先显 title(后端返 TaskRecord),无则回退 id,防裸 UUID
? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { title: r.title || 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 || '', backup_path: r.backup_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
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: ''
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': {
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
}
default: return ''
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,502 @@
//! ToolCard 纯函数与常量 —— 工具卡片渲染逻辑层
//!
//! 职责:
//! - 工具结果 JSON 解析(parseResult)与人类可读摘要格式化(formatToolResult/toolResultSummary)
//! - 工具元数据(图标/分类/显示名片段/路径短化/字节格式化)
//! - 审批参数键值转换(toolArgsEntries/formatArgValue/argLabel)
//! - run_command 输出合并与截断(combineOutputs/combineAndTruncateLines)
//! - 工具失败语义检测(isToolFailure)
//!
//! 抽离自 ToolCard.vue(原 <script lang="ts"> 模块级纯函数 + ToolResult 类型 + 图标常量,
//! 及原 <script setup> 中仅依赖 i18n.t 的 toolResultSummary)。零行为变更,纯搬迁。
//!
//! 设计:
//! - composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t(对齐 aiShared.ts/
//! useAiEvents.ts/useAiStream.ts 等同款约定),而非 useI18n()。
//! - 全部为纯函数(入参出参,无响应式无副作用),供 ToolCard.vue 模板/setup 直接 import 调用。
//! - store 依赖的工具显示名(toolDisplayName/displayArgValue/projectNameById/taskNameById)
//! 仍留 ToolCard.vue setup(耦合 projectStore,非纯函数)。
//!
//! 耦合:
//! - i18n.global.t(翻译)
//! - AiToolCallInfo(@/api/types)
import i18n from '@/i18n'
import type { AiToolCallInfo } from '@/api/types'
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
const t = ((i18n as any).global.t as (k: string, p?: Record<string, unknown>) => string).bind((i18n as any).global)
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
export interface ToolResult {
path?: string
pattern?: string
from?: string
to?: string
content?: string
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
// UX-260618-05~12:补全后端返回前端消费的字段(review_rounds/truncated/bytes_moved/has_more/total/entries 已存在)
task_id?: string
execution_id?: string
target_status?: string
note?: string
diff?: string
matches_found?: number
backup_path?: string
returned_lines?: number
items?: Array<Record<string, unknown>>
modified?: number | null
old_size?: number | null
encoding?: string
cross_volume?: boolean
}
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
export const dirIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
export const fileIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
return tc.status === 'running'
|| tc.status === 'pending_approval'
|| tc.status === 'rejected'
|| tc.name === 'write_file'
}
/**
* UX-260616-01: 工具执行失败检测(completed 状态下的失败语义)。
*
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
* 此函数区分两类失败:
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
* - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
*
* 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。
* running/pending_approval 自然 false(无 result 或 result 未稳定)。
*/
export function isToolFailure(tc: AiToolCallInfo): boolean {
if (tc.status !== 'completed') return false
// run_command:结构化 exit_code 判定(可靠)
if (tc.name === 'run_command') {
const r = parseResult(tc.result)
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
// run_command 但无 exit_code(异常)→ 保守判失败
if (r) return true
}
// 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀
if (!parseResult(tc.result)) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
// 误伤 search_files 命中 error.log / run_command stderr 含 "warning" 等正常产出)。
// 仅当某行以「执行失败」(中文 err_msg 前缀)/「Error:」/「Failed:」开头才判失败。
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
}
export function formatToolName(name: string): string {
if (!name) return ''
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
/** run_command 命令首行截断(头部展示;多行命令取首行,超长省略) */
export function shortCmd(cmd: string, max = 48): string {
const firstLine = cmd.split('\n')[0].replace(/\s+/g, ' ').trim()
return firstLine.length > max ? firstLine.slice(0, max) + '…' : firstLine
}
export function formatJson(data: unknown): string {
try {
return JSON.stringify(data, null, 2)
} catch {
return String(data)
}
}
/** 审批参数 key → 友好标签映射(动态 i18n);未命中回退原 key */
export 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 报错) */
export function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val: unknown }> {
if (args && typeof args === 'object' && !Array.isArray(args)) {
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({
key,
label: argLabel(key),
val,
}))
}
return []
}
/** 格式化单个审批参数值:字符串直接展示(超长截断),对象/数组 JSON 化后截断 */
export function formatArgValue(val: unknown): string {
if (val == null) return ''
if (typeof val === 'string') return val.length > 300 ? val.slice(0, 300) + '…' : val
if (typeof val === 'object') {
try {
const s = JSON.stringify(val)
return s.length > 300 ? s.slice(0, 300) + '…' : s
} catch {
return String(val)
}
}
return String(val)
}
/** 安全解析工具结果 JSON;list_* 运行时是数组,类型归 ToolResult 靠 Array.isArray 区分 */
export function parseResult(result: unknown): ToolResult | null {
if (!result) return null
try {
return (typeof result === 'string' ? JSON.parse(result) : result) as ToolResult
} catch {
return null
}
}
export 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'
if (name.includes('create')) return 'create'
if (name.includes('delete')) return 'delete'
if (name.includes('list')) return 'list'
return 'default'
}
export function toolIcon(name: string): string {
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'list_directory') return dirIcon
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
}
export function shortPath(p: string): string {
const parts = p.replace(/\\/g, '/').split('/')
return parts.length <= 2 ? p : '.../' + parts.slice(-2).join('/')
}
/** 格式化字节数(兜底:undefined/null/负/NaN/Infinity 统一降级为 '0 B',防渲染异常) */
export 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 时返回空串 */
export function argString(args: unknown, key: string): string {
if (args && typeof args === 'object' && !Array.isArray(args)) {
const v = (args as Record<string, unknown>)[key]
return typeof v === 'string' ? v : ''
}
return ''
}
/** size_diff 数字 → "+N"/"-N"/"+0" 字符串(负数补减号,正数补加号) */
export function formatSizeDiff(diff: number | undefined | null): string {
const n = typeof diff === 'number' ? diff : 0
return n >= 0 ? `+${n}` : `${n}`
}
/** SW-260618-19: 合并 stdout/stderr(空串跳过),供 cmdOutput 展开 + combineAndTruncateLines 截断共享 DRY */
export function combineOutputs(stdout: string | undefined, stderr: string | undefined): string {
const parts: string[] = []
if (stdout) parts.push(stdout)
if (stderr) parts.push(stderr)
return parts.join('\n')
}
/** 合并 stdout/stderr 取前 N 行,返回截断后文本 + 是否截断 + 总行数(合并复用 combineOutputs) */
export function combineAndTruncateLines(stdout: string | undefined, stderr: string | undefined, maxLines: number): { text: string; truncated: boolean; total: number } {
const combined = combineOutputs(stdout, stderr)
if (!combined) return { text: '', truncated: false, total: 0 }
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 }
}
/**
* run_command 结果摘要:exit_code 标题 + stdout/stderr 前 N 行 + 总行数提示(截断,不全量显示)。
* 输出可能很大(编译/find/cat 大文件),仅显示前若干行 + 总行数提示,防撑爆卡片。
*/
export 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')
}
/** file_info 结果摘要:存在性 + 大小 + 行数(文本)/二进制/目录 标注 */
export 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[] = []
// UX-260618-12: file_info modified 时间(epoch_ms → 客户端 toLocaleString)
if (typeof r.modified === 'number' && r.modified > 0) {
extras.push(t('aiTool.fileInfoModified', { time: new Date(r.modified).toLocaleString() }))
}
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('')
}
/**
* 通用工具结果格式化(非文件类工具)。
* UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。
* read_file/list_directory/write_file 有模板特化分支,不进此函数。
*/
export function formatToolResult(tc: AiToolCallInfo): string {
const r = parseResult(tc.result)
if (!r) {
// 非 JSON(纯文本:审批占位/错误/拒绝)→ 原样显示,审批占位时补拟执行参数避免空白
const raw = typeof tc.result === 'string' ? tc.result : ''
if (raw && tc.args && typeof tc.args === 'object') {
const a = tc.args as Record<string, unknown>
const detail = typeof a.title === 'string' ? a.title
: typeof a.name === 'string' ? a.name
: typeof a.path === 'string' ? a.path
: ''
if (detail) return t('aiTool.resultDetail', { raw, detail })
}
return raw
}
switch (tc.name) {
case 'list_projects':
case 'list_tasks':
case 'list_ideas':
case 'list_trash': {
// UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组),渲染结构化列表替代裸 JSON
const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : [])
const total = typeof r.total === 'number' ? r.total : items.length
const head = tc.name === 'list_projects' ? t('aiTool.projectCount', { n: total })
: tc.name === 'list_tasks' ? t('aiTool.taskCount', { n: total })
: tc.name === 'list_ideas' ? t('aiTool.ideaCount', { n: total })
: t('aiTool.trashCount', { n: total })
const lines = items.slice(0, 10).map((it: Record<string, unknown>) => {
const label = typeof it.name === 'string' ? it.name
: typeof it.title === 'string' ? it.title
: typeof it.id === 'string' ? it.id
: ''
const status = typeof it.status === 'string' ? ` [${it.status}]` : ''
const stack = typeof it.stack === 'string' ? ` (${it.stack})` : ''
return label ? `- ${label}${status}${stack}` : ''
}).filter(Boolean)
const parts = [head, ...lines]
if (r.has_more) parts.push(t('aiTool.listHasMore', { n: items.length }))
return parts.join('\n')
}
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':
// UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
// UX-260618-14: 后端返 TaskRecord(含 title),优先显 title,无则回退 id(防裸 UUID)
return typeof r.review_rounds === 'number' && r.review_rounds > 0
? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { title: r.title || 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 || '', backup_path: r.backup_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
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: 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)
case 'run_workflow': {
// UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
if (!execId) return formatJson(r)
return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId })
}
default:
return formatJson(r)
}
}
/**
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
* read_file/list_directory/write_file 有模板特化分支,header 摘要仍显示关键计数。
*/
export function toolResultSummary(tc: AiToolCallInfo): string {
if (tc.status !== 'completed') return ''
const r = parseResult(tc.result)
if (!r) return ''
switch (tc.name) {
case 'list_tasks':
case 'list_projects':
case 'list_ideas':
case 'list_trash': {
// UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数
const n = Array.isArray(r) ? r.length
: (typeof r.total === 'number' ? r.total
: (Array.isArray(r.items) ? r.items.length : 0))
if (!n) return ''
const key = tc.name === 'list_projects' ? 'projectCount'
: tc.name === 'list_tasks' ? 'taskCount'
: tc.name === 'list_ideas' ? 'ideaCount'
: 'trashCount'
return t(`aiTool.${key}`, { n })
}
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
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 'update_task': return r.updated !== false
? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' })
: t('aiTool.updated')
case 'advance_task': return typeof r.review_rounds === 'number' && r.review_rounds > 0
// UX-260618-14: 优先显 title(后端返 TaskRecord),无则回退 id,防裸 UUID
? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds })
: t('aiTool.advancedTask', { title: r.title || 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 || '', backup_path: r.backup_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
? (r.cross_volume
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
: t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }))
: ''
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': {
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
const execId = typeof r.execution_id === 'string' && r.execution_id
? r.execution_id
: (typeof r.id === 'string' ? r.id : '')
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
}
default: return ''
}
}