重构: 拆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:
@@ -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 各自渲染互不干扰)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
502
src/composables/ai/useToolCard.ts
Normal file
502
src/composables/ai/useToolCard.ts
Normal 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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user