重构: ToolCard.vue 拆分(1167→374 行,REFACTOR-260619-04)

纯结构拆分,逻辑等价,渲染/交互/接口契约不变:
- 抽 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 渲染分支完整核对
This commit is contained in:
2026-06-19 21:45:09 +08:00
parent ba47f44ab8
commit 74f38609e0
4 changed files with 844 additions and 893 deletions

View File

@@ -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<string, 'id' | 'project_id'> = {
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<string, 'id' | 'task_id'> = {
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
}
}

View File

@@ -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
}