BUG-260625-01 grep 单文件路径走 grep_one_file(旧 read_dir 报 os error 267 目录无效);BUG-260625-02 read_file search 模式渲染命中行 matches(旧取 content 显 0 行误判读取失败)。
667 lines
30 KiB
TypeScript
667 lines
30 KiB
TypeScript
//! ToolCard 纯函数与常量 —— 工具卡片渲染逻辑层
|
|
//!
|
|
//! 职责:
|
|
//! - 工具结果 JSON 解析(parseResult)与人类可读摘要格式化(formatToolResult/toolResultSummary)
|
|
//! - 工具元数据(图标/分类/显示名片段/路径短化/字节格式化)
|
|
//! - 审批参数键值转换(toolArgsEntries/formatArgValue/argLabel)
|
|
//! - run_command 输出合并与截断(combineOutputs/combineAndTruncateLines)
|
|
//! - 工具失败语义检测(isToolFailure)
|
|
//!
|
|
//! 抽离自 ToolCard.vue(原 <script lang="ts"> 模块级纯函数 + ToolResult 类型 + 图标常量,
|
|
//! 及原 <script setup> 中仅依赖 i18n.t 的 toolResultSummary)。零行为变更,纯搬迁。
|
|
//!
|
|
//! 设计:
|
|
//! - 非组件上下文(无 setup),用 @/i18n/i18n-helpers 的类型安全 t(规避 vue-i18n TS2589)。
|
|
//! - 全部为纯函数(入参出参,无响应式无副作用),供 ToolCard.vue 模板/setup 直接 import 调用。
|
|
//! - store 依赖的工具显示名(toolDisplayName/displayArgValue/projectNameById/taskNameById)
|
|
//! 仍留 ToolCard.vue setup(耦合 projectStore,非纯函数)。
|
|
//!
|
|
//! 耦合:
|
|
//! - i18n 翻译(经 @/i18n/i18n-helpers)
|
|
//! - AiToolCallInfo(@/api/types)
|
|
|
|
import { t } from '@/i18n/i18n-helpers'
|
|
import type { AiToolCallInfo } from '@/api/types'
|
|
|
|
/** 工具结果 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
|
|
// http_request result fields
|
|
method?: string
|
|
url?: string
|
|
body?: string
|
|
body_bytes?: number
|
|
elapsed_ms?: number
|
|
// read_file search 模式(BUG-260625-02):传 search 参数返命中行 matches[{line,content}] 非全文 content
|
|
search?: string
|
|
// grep 跨文件内容搜索结果字段(F-260621):
|
|
// - output_mode: content(默认)/ files_with_matches / count
|
|
// - matches: content 模式命中行 [{file,line,content,context?}]
|
|
// - files: files_with_matches 模式命中文件名数组
|
|
// - counts: count 模式每文件命中行数 [{file,count}]
|
|
// - total: 命中总数(content 模式=命中行数,count 模式=命中行数,files_with_matches 模式=命中文件数)
|
|
// - total_files: count 模式命中文件数
|
|
output_mode?: string
|
|
matches?: Array<{ file?: string; line?: number | null; content?: string; context?: string }>
|
|
files?: string[]
|
|
counts?: Array<{ file?: string; count?: number }>
|
|
total_files?: number
|
|
}
|
|
|
|
/** 内联 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>'
|
|
/** grep 工具放大镜图标(跨文件内容搜索,F-260621) */
|
|
export const searchIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></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 === 'grep') 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>'
|
|
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 有模板特化分支,不进此函数。
|
|
*
|
|
* P0-3: switch 17 case 拆为策略表 TOOL_FORMATTERS(name → 纯函数),formatToolResult 查表分发。
|
|
* 新增工具只在此表加一行,零改 formatToolResult 主体。逻辑与原 switch 等价(零行为变更)。
|
|
*/
|
|
|
|
// list_* 系列(list_projects/list_tasks/list_ideas/list_trash)共享格式化:
|
|
// 渲染结构化列表替代裸 JSON,头行按 name 选 i18n key,条目取 name/title/id + status/stack 标注。
|
|
function formatListResult(r: ToolResult, name: string): string {
|
|
// UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组)
|
|
const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : [])
|
|
const total = typeof r.total === 'number' ? r.total : items.length
|
|
const head = name === 'list_projects' ? t('aiTool.projectCount', { n: total })
|
|
: name === 'list_tasks' ? t('aiTool.taskCount', { n: total })
|
|
: 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')
|
|
}
|
|
|
|
/** advance_task 共享格式化(body + summary 一致): review_rounds>0 显退回累加轮数,否则首推原文案 */
|
|
function formatAdvanceTask(r: ToolResult): string {
|
|
// 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 || '' })
|
|
}
|
|
|
|
/** update_task 共享格式化: updated!==false 显字段更新,否则通用 updated */
|
|
function formatUpdateTask(r: ToolResult): string {
|
|
// UX-260619-04: title 优先(后端补返 title),无则回退 id(防裸 UUID),对齐 advance_task。
|
|
return r.updated !== false
|
|
? t('aiTool.updatedTaskField', { title: r.title || r.id || '', field: r.field || '' })
|
|
: t('aiTool.updated')
|
|
}
|
|
|
|
/** delete_task/delete_project 共享格式化: deleted 标志 */
|
|
function formatDelete(r: ToolResult): string {
|
|
return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
|
|
}
|
|
|
|
/** delete_file 共享格式化: permanent 走永久删,否则软删带 backup_path */
|
|
function formatDeleteFile(r: ToolResult): string {
|
|
return r.permanent
|
|
? t('aiTool.deleteFilePermanent', { path: r.path || '' })
|
|
: t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' })
|
|
}
|
|
|
|
/** patch_file 共享格式化: changed===false 显无变化,否则显 size_diff */
|
|
function formatPatchFile(r: ToolResult): string {
|
|
return r.changed === false
|
|
? t('aiTool.patchedNoChange', { path: r.path || '' })
|
|
: t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) })
|
|
}
|
|
|
|
/** append_file 共享格式化: path + bytes_written */
|
|
function formatAppendFile(r: ToolResult): string {
|
|
return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
|
|
}
|
|
|
|
/** search_files 共享格式化: total/results.length 取命中数 */
|
|
function formatSearchFiles(r: ToolResult): string {
|
|
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0)
|
|
return t('aiTool.foundN', { n })
|
|
}
|
|
|
|
/**
|
|
* grep 摘要/命中计数标签(F-260621):按 output_mode 取合适文案,三分支:
|
|
* - content(默认):「命中 N 处」(total = 命中行数,total 缺省回退 matches.length)
|
|
* - files_with_matches:「命中 N 文件」(files.length)
|
|
* - count:「命中 N 处 / M 文件」(total 行数 + total_files)
|
|
*
|
|
* 抽公共单一函数 G-1:原 ToolResultBody.vue grepHitLabel 与此处 formatGrep 三分支逐行等价,
|
|
* 现 export 此函数,ToolResultBody.vue 复用,消除文案漂移风险。i18n 走 @/i18n/i18n-helpers 类型安全 t。
|
|
*/
|
|
export function formatGrep(r: ToolResult): string {
|
|
const mode = r.output_mode
|
|
if (mode === 'files_with_matches') {
|
|
const n = Array.isArray(r.files) ? r.files.length : 0
|
|
return t('aiTool.grepFilesN', { n })
|
|
}
|
|
if (mode === 'count') {
|
|
const total = typeof r.total === 'number' ? r.total : 0
|
|
const files = typeof r.total_files === 'number' ? r.total_files : 0
|
|
return t('aiTool.grepCountSummary', { n: total, files })
|
|
}
|
|
// content(默认)
|
|
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.matches) ? r.matches.length : 0)
|
|
return t('aiTool.grepHitsN', { n })
|
|
}
|
|
|
|
/**
|
|
* rename_file 格式化(body 版): renamed===false 走 formatJson 兜底(summary 版走空串)。
|
|
* 故 body 与 summary 分别登记不同 formatter。
|
|
*/
|
|
function formatRenameFile(r: ToolResult): string {
|
|
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)
|
|
}
|
|
|
|
/** bind_directory 格式化(body 版): bound===false 走 formatJson 兜底(summary 版走空串) */
|
|
function formatBindDir(r: ToolResult): string {
|
|
return r.bound
|
|
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
|
|
: formatJson(r)
|
|
}
|
|
|
|
/** run_workflow 格式化(body 版):无 execution_id 走 formatJson,否则显完整三要素(task_id/target_status/exec) */
|
|
function formatRunWorkflow(r: ToolResult): string {
|
|
// 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 })
|
|
}
|
|
|
|
/** 工具结果格式化策略表(name → 纯函数)。新增工具在此加一行,不动 formatToolResult 主体。 */
|
|
const TOOL_FORMATTERS: Record<string, (r: ToolResult) => string> = {
|
|
list_projects: r => formatListResult(r, 'list_projects'),
|
|
list_tasks: r => formatListResult(r, 'list_tasks'),
|
|
list_ideas: r => formatListResult(r, 'list_ideas'),
|
|
list_trash: r => formatListResult(r, 'list_trash'),
|
|
write_file: r => `${r.path} (${formatBytes(r.bytes_written)})`,
|
|
update_task: formatUpdateTask,
|
|
advance_task: formatAdvanceTask,
|
|
delete_task: formatDelete,
|
|
delete_file: formatDeleteFile,
|
|
run_command: formatCommandResult,
|
|
patch_file: formatPatchFile,
|
|
append_file: formatAppendFile,
|
|
rename_file: formatRenameFile,
|
|
search_files: formatSearchFiles,
|
|
file_info: formatFileInfoResult,
|
|
bind_directory: formatBindDir,
|
|
run_workflow: formatRunWorkflow,
|
|
http_request: formatHttpResult,
|
|
grep: formatGrep,
|
|
}
|
|
|
|
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
|
|
}
|
|
const fn = TOOL_FORMATTERS[tc.name]
|
|
return fn ? fn(r) : formatJson(r)
|
|
}
|
|
|
|
/**
|
|
* http_request 结果摘要:状态码标题(method + 最终 URL) + 截断的 body(前 N 行) + 耗时/字节数。
|
|
* 后端已截断 body 到 50KB + parse 格式化(json pretty/auto),此处仅做行数限制(前 20 行)
|
|
* 防大 JSON 撑爆折叠态卡片。展开态由 ToolCard.vue http_request 分支全量展示。
|
|
*/
|
|
export function formatHttpResult(r: ToolResult): string {
|
|
const status = typeof r.status === 'number' ? r.status : 0
|
|
const method = typeof r.method === 'string' ? r.method : 'HTTP'
|
|
const url = typeof r.url === 'string' ? r.url : ''
|
|
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
|
const ok = status >= 200 && status < 300
|
|
const head = ok
|
|
? t('aiTool.httpOk', { method, status, ms })
|
|
: t('aiTool.httpFailed', { method, status })
|
|
const parts: string[] = [head, url]
|
|
const body = typeof r.body === 'string' ? r.body : ''
|
|
if (body) {
|
|
const MAX_LINES = 20
|
|
const lines = body.split('\n')
|
|
if (lines.length > MAX_LINES) {
|
|
parts.push(lines.slice(0, MAX_LINES).join('\n'))
|
|
parts.push(t('aiTool.commandOutputLines', { n: lines.length }))
|
|
} else {
|
|
parts.push(body)
|
|
}
|
|
}
|
|
const trunc = r.truncated === true
|
|
const bytes = typeof r.body_bytes === 'number' ? r.body_bytes : 0
|
|
if (trunc) {
|
|
parts.push(t('aiTool.httpTruncated', { bytes: formatBytes(bytes) }))
|
|
}
|
|
return parts.join('\n')
|
|
}
|
|
|
|
/**
|
|
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
|
|
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
|
|
* read_file/list_directory/write_file 有模板特化分支,header 摘要仍显示关键计数。
|
|
*
|
|
* P0-3: switch 拆为策略表 TOOL_SUMMARIES(name → 纯函数),toolResultSummary 查表分发。
|
|
* 与 TOOL_FORMATTERS 不共用:summary 版部分 case 输出更短(create/update_project/restore/purge 仅 summary 有;
|
|
* rename/bind 的 false 分支返空串而非 formatJson;run_workflow 用 workflowTriggeredShort;list_* 多 has_more 判断差异)。
|
|
* 共享语义的纯函数(formatAdvanceTask/formatUpdateTask/formatDelete/formatDeleteFile/formatPatchFile/
|
|
* formatAppendFile/formatSearchFiles)两个表共用,DRY。零行为变更。
|
|
*/
|
|
|
|
/** list_* summary 版:n=0 返空串(折叠态无噪音),按 name 选 i18n key */
|
|
function summaryList(r: ToolResult, name: string): string {
|
|
// 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 = name === 'list_projects' ? 'projectCount'
|
|
: name === 'list_tasks' ? 'taskCount'
|
|
: name === 'list_ideas' ? 'ideaCount'
|
|
: 'trashCount'
|
|
return t(`aiTool.${key}`, { n })
|
|
}
|
|
|
|
/** run_command summary 版:仅 exit_code + duration_ms 一行(不带输出) */
|
|
function summaryRunCommand(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
|
|
return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code })
|
|
}
|
|
|
|
/** file_info summary 版:存在性 + 大小(不带 modified/lines/binary extras) */
|
|
function summaryFileInfo(r: ToolResult): string {
|
|
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) })
|
|
}
|
|
|
|
/** rename_file summary 版:renamed===false 返空串(body 版走 formatJson) */
|
|
function summaryRenameFile(r: ToolResult): string {
|
|
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) }))
|
|
: ''
|
|
}
|
|
|
|
/** bind_directory summary 版:bound===false 返空串(body 版走 formatJson) */
|
|
function summaryBindDir(r: ToolResult): string {
|
|
return r.bound
|
|
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
|
|
: ''
|
|
}
|
|
|
|
/** run_workflow summary 版:无 execution_id 返空串,有则显 workflowTriggeredShort(body 版显完整三要素) */
|
|
function summaryRunWorkflow(r: ToolResult): string {
|
|
// 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 }) : ''
|
|
}
|
|
|
|
/** http_request summary 版:仅状态码标题(method + status,不带 url/body) */
|
|
function summaryHttpRequest(r: ToolResult): string {
|
|
const status = typeof r.status === 'number' ? r.status : 0
|
|
const method = typeof r.method === 'string' ? r.method : 'HTTP'
|
|
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
|
const ok = status >= 200 && status < 300
|
|
return ok
|
|
? t('aiTool.httpOk', { method, status, ms })
|
|
: t('aiTool.httpFailed', { method, status })
|
|
}
|
|
|
|
/** 工具结果摘要策略表(name → 纯函数)。仅 summary 独有的工具(create/update_project/restore/purge)在此登记。 */
|
|
const TOOL_SUMMARIES: Record<string, (r: ToolResult) => string> = {
|
|
list_projects: r => summaryList(r, 'list_projects'),
|
|
list_tasks: r => summaryList(r, 'list_tasks'),
|
|
list_ideas: r => summaryList(r, 'list_ideas'),
|
|
list_trash: r => summaryList(r, 'list_trash'),
|
|
create_project: r => r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created'),
|
|
create_idea: r => r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created'),
|
|
create_task: r => r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created'),
|
|
update_project: r => r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated'),
|
|
update_task: formatUpdateTask,
|
|
advance_task: formatAdvanceTask,
|
|
delete_project: formatDelete,
|
|
delete_task: formatDelete,
|
|
delete_file: formatDeleteFile,
|
|
restore_project: r => r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective'),
|
|
purge_project: r => r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective'),
|
|
run_command: summaryRunCommand,
|
|
patch_file: formatPatchFile,
|
|
append_file: formatAppendFile,
|
|
rename_file: summaryRenameFile,
|
|
search_files: formatSearchFiles,
|
|
file_info: summaryFileInfo,
|
|
bind_directory: summaryBindDir,
|
|
run_workflow: summaryRunWorkflow,
|
|
http_request: summaryHttpRequest,
|
|
grep: formatGrep,
|
|
}
|
|
|
|
export function toolResultSummary(tc: AiToolCallInfo): string {
|
|
if (tc.status !== 'completed') return ''
|
|
const r = parseResult(tc.result)
|
|
if (!r) return ''
|
|
const fn = TOOL_SUMMARIES[tc.name]
|
|
return fn ? fn(r) : ''
|
|
}
|