From 74f38609e04f89d6b50fdf6a84dec846086d1aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 21:45:09 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20ToolCard.vue=20=E6=8B=86?= =?UTF-8?q?=E5=88=86(1167=E2=86=92374=20=E8=A1=8C,REFACTOR-260619-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯结构拆分,逻辑等价,渲染/交互/接口契约不变: - 抽 ToolResultBody.vue(545 行,8 工具结果渲染分支:read_file/list_directory/ search_files/write_file/patch_file/run_command/http_request/通用 CRUD) - 抽 useToolCardRender.ts(45 行,parseDiffLines 渲染纯函数) - 抽 useToolCardHeader.ts(154 行,toolDisplayName/displayArgValue/projectNameById 头部显示) - ToolCard.vue 瘦身至 374 行(容器+头部+审批区+状态机,结果展示委托 ToolResultBody) - props/emits 契约不变(ToolCardList/AiChat/MessageList 调用零改动) vue-tsc EXIT 0 + 8/8 渲染分支完整核对 --- src/components/ToolCard.vue | 993 +++--------------------- src/components/ToolResultBody.vue | 545 +++++++++++++ src/composables/ai/useToolCardHeader.ts | 154 ++++ src/composables/ai/useToolCardRender.ts | 45 ++ 4 files changed, 844 insertions(+), 893 deletions(-) create mode 100644 src/components/ToolResultBody.vue create mode 100644 src/composables/ai/useToolCardHeader.ts create mode 100644 src/composables/ai/useToolCardRender.ts diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 188c76a..2d09c2d 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -6,7 +6,7 @@ { 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed }, ]" > - +
@@ -31,16 +31,16 @@
- +
-
-
+
+
{{ arg.label }} {{ displayArgValue(arg) }}
⚠ {{ tc.reason }}
- +
{{ ln.text }}{{ '\n' }}
@@ -68,134 +68,14 @@ {{ tc.result || $t('aiTool.rejectedHint') }}
- -
-
- - {{ parsed?.path }} - {{ parsed?.has_more - ? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 }) - : `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }} - -
-
{{ parsed?.content }}
-
- - -
-
- - {{ parsed?.path }} - {{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }} - {{ $t('aiTool.dirTruncated', { n: 1000 }) }} -
-
-
- - {{ entry.name }} - {{ formatBytes(entry.size) }} -
-
-
- - -
-
- - {{ parsed?.path }} - 「{{ parsed?.pattern }}」 - {{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }} -
-
-
- - {{ entry.path }} - {{ formatBytes(entry.size) }} -
-
-
- - -
- - {{ parsed?.path }} - {{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }} - {{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }} - {{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }} -
- - -
-
- - {{ parsed?.path }} - {{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }} - {{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }} -
-
⚠ {{ parsed.warning }}
-
-
{{ ln.text }}{{ '\n' }}
-
-
- - -
-
- $ - {{ argString(tc.args, 'command') }} - -
-
{{ cmdOutput }}
-
- - -
-
- {{ parsed?.method || 'GET' }} - {{ parsed?.url }} - {{ parsed?.status }} - -
-
- ⚠ {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }} -
-
{{ parsed.body }}
-
- - -
-
⚠ {{ $t('aiTool.executionFailedHint') }}
- {{ formatToolResult(tc) }} -
+ +
@@ -216,38 +96,22 @@ diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue new file mode 100644 index 0000000..9602b00 --- /dev/null +++ b/src/components/ToolResultBody.vue @@ -0,0 +1,545 @@ + + + + + diff --git a/src/composables/ai/useToolCardHeader.ts b/src/composables/ai/useToolCardHeader.ts new file mode 100644 index 0000000..e6f1d63 --- /dev/null +++ b/src/composables/ai/useToolCardHeader.ts @@ -0,0 +1,154 @@ +/** + * ToolCard 头部显示逻辑(从 ToolCard.vue 抽离)。 + * + * 职责:工具显示名 + 审批参数值的「语义化回显」(裸 id → 项目名/任务标题), + * 以及 http url → host 精简。这些依赖 project store(响应式),故以 composable 形式封装, + * 传入 tc 即得 { toolDisplayName, displayArgValue }。 + * + * 不含响应式状态(纯函数 + store 读),逻辑与原 ToolCard.vue 内联实现一字等价。 + */ +import { useProjectStore } from '@/stores/project' +import { useI18n } from 'vue-i18n' +import { + formatToolName, + shortCmd, + toolArgsEntries, + formatArgValue, + shortPath, + argString, +} from '@/composables/ai/useToolCard' +import type { AiToolCallInfo } from '@/api/types' + +/** 工具名 → 该工具中代表项目 id 的参数 key(仅项目类工具特化;delete_task 的 id 是任务 id,不在此列) */ +const PROJECT_ID_TOOL_ARG: Record = { + delete_project: 'id', + restore_project: 'id', + purge_project: 'id', + update_project: 'id', + bind_directory: 'id', + create_task: 'project_id', +} + +/** + * 工具名 → 该工具中代表任务 id 的参数 key(任务类工具特化)。 + * advance_task 的任务 id 在 "id" key,run_workflow 在 "task_id" key。 + */ +const TASK_ID_TOOL_ARG: Record = { + advance_task: 'id', + run_workflow: 'task_id', +} + +/** + * ToolCard 头部/审批参数显示辅助。 + * @param getTc 取当前 tc 的函数(避免 props.tc 在 composable 调用时机脱节,由调用方透传) + */ +export function useToolCardHeader(getTc: () => AiToolCallInfo) { + const t = useI18n().t + const projectStore = useProjectStore() + + /** + * 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目, + * 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。 + * 复用 project store 已加载列表,不新增网络请求;响应式由 store 数组保证。 + */ + function projectNameById(id: string): string | undefined { + return projectStore.projects.find(p => p.id === id)?.name + ?? projectStore.deletedProjects.find(p => p.id === id)?.name + } + + /** + * 按 id 查任务标题:审批 advance_task/run_workflow 的 task_id 时,把裸 UUID 转标题。 + * 复用 project store 已加载的 state.tasks(仅当前项目范围),不新增网络请求。 + */ + function taskNameById(id: string): string | undefined { + return projectStore.tasks.find(tk => tk.id === id)?.title + } + + /** + * 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示 + * 「项目已不存在」提示 + 原 id。非项目工具或其他 key 走通用 formatArgValue。 + */ + function displayArgValue(arg: { key: string; val: unknown }): string { + const tc = getTc() + const projectArgKey = PROJECT_ID_TOOL_ARG[tc.name] + if (projectArgKey && arg.key === projectArgKey) { + const id = typeof arg.val === 'string' ? arg.val : '' + if (id) { + const name = projectNameById(id) + if (name) return t('aiTool.projectLabel', { name }) + return t('aiTool.projectIdNotFound', { id }) + } + } + // UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题 + const taskArgKey = TASK_ID_TOOL_ARG[tc.name] + if (taskArgKey && arg.key === taskArgKey) { + const id = typeof arg.val === 'string' ? arg.val : '' + if (id) { + const name = taskNameById(id) + if (name) return t('aiTool.taskLabel', { name }) + return t('aiTool.taskIdNotFound', { id }) + } + } + return formatArgValue(arg.val) + } + + /** + * 工具显示名称(含目标摘要);文件类按 path,项目类按 id 解析项目名,create_* 用固定文案。 + */ + function toolDisplayName(tc: AiToolCallInfo): string { + const p = argString(tc.args, 'path') + switch (tc.name) { + case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback') + case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback') + case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback') + case 'delete_project': + case 'restore_project': + case 'purge_project': + case 'update_project': { + const idKey = PROJECT_ID_TOOL_ARG[tc.name] + const id = idKey ? argString(tc.args, idKey) : '' + const name = id ? projectNameById(id) : '' + const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix' + const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback' + return name ? `${t('aiTool.' + prefixKey)}「${name}」` : t('aiTool.' + fallbackKey) + } + case 'create_task': return t('aiTool.createTask') + case 'create_project': return t('aiTool.createProject') + case 'run_command': { + const cmd = argString(tc.args, 'command') + return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name) + } + case 'http_request': { + const method = argString(tc.args, 'method') || 'GET' + const url = argString(tc.args, 'url') + const host = httpHost(url) + return host ? `${method} ${host}` : t('aiTool.httpRequestFallback') + } + default: return formatToolName(tc.name) + } + } + + /** 审批参数键值对(头部 toolArgsEntries 透传,本 composable 仅暴露便于复用) */ + function argsEntries() { + return toolArgsEntries(getTc().args) + } + + return { toolDisplayName, displayArgValue, argsEntries } +} + +/** + * http_request url → host 显示用(折叠态头部精简,不全显 url)。 + * 取 scheme://host[:port],剥 path/query;非 http(s) url 返回空串回退通用名。 + * 注:用 URL 构造器容错,非法 url 抛错时回退原 url 截断。 + */ +export function httpHost(raw: string): string { + if (!raw) return '' + try { + const u = new URL(raw) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return '' + const portPart = u.port ? `:${u.port}` : '' + return `${u.hostname}${portPart}` + } catch { + return raw.length > 40 ? raw.slice(0, 40) + '…' : raw + } +} diff --git a/src/composables/ai/useToolCardRender.ts b/src/composables/ai/useToolCardRender.ts new file mode 100644 index 0000000..14bd60e --- /dev/null +++ b/src/composables/ai/useToolCardRender.ts @@ -0,0 +1,45 @@ +/** + * ToolCard 渲染层纯函数辅助(从 ToolCard.vue 抽离)。 + * + * 职责:diff 行解析等无状态渲染纯函数。响应式 computed/ref 仍留各自组件, + * 这里只提供「输入→输出」的确定性变换,便于单测与跨组件复用(write_file 审批 diff + * 与 patch_file 结果 diff 共用同一解析逻辑)。 + * + * 注:策略表(parseResult/formatBytes/formatSizeDiff 等带语义的)仍在 useToolCard.ts, + * 本文件仅收「与模板渲染强耦合、原散落在 ToolCard.vue 内联」的纯展示函数。 + */ + +/** diff 行种类:增/删/上下文 */ +export type DiffLineKind = 'add' | 'del' | 'ctx' + +/** 一行 diff 解析结果(kind 用于模板着色,text 原样显示) */ +export interface DiffLine { + kind: DiffLineKind + text: string +} + +/** + * 解析 unified diff 文本 → DiffLine[]。 + * + * 来源(AE-2025-03 / UX-260618-06):后端 generate_diff 输出 '+新行/-旧行/(空格)上下文', + * 逐行拆 → {kind, text} 供模板按 kind 着色(add 绿/del 红/ctx 灰)。 + * + * - 空行过滤(generate_diff 末尾保留单尾换行 → split 末尾有空串元素) + * - maxLines 截断:超长 diff 截到前 N 行并追加「…」占位(防撑爆卡片);undefined 不截断 + * + * 纯函数,无副作用,相同输入恒定输出。 + */ +export function parseDiffLines(text: string | undefined | null, maxLines?: number): DiffLine[] { + if (!text) return [] + const lines = text.split('\n').map(line => { + if (line === '') return null + if (line.startsWith('+')) return { kind: 'add' as const, text: line } + if (line.startsWith('-')) return { kind: 'del' as const, text: line } + return { kind: 'ctx' as const, text: line } + }).filter((x): x is DiffLine => x !== null) + if (maxLines !== undefined && lines.length > maxLines) { + lines.splice(maxLines) + lines.push({ kind: 'ctx', text: '…' }) + } + return lines +}