重构: P0前端架构(useAiSend/useAiEvents/useToolCard拆分+status union全)

squash合并:
- P0-1 useAiSend审批抽离(useAiApproval)
- P0-2 useAiEvents switch拆3域handler
- P0-3 useToolCard formatToolResult策略表
- P0-4 status union(Idea+Task+Project+Workflow+AiToolCall)
This commit is contained in:
lxy
2026-06-19 15:04:04 +08:00
parent 998a2f243d
commit 5feeaa6cb0
6 changed files with 435 additions and 262 deletions
+228 -156
View File
@@ -329,7 +329,132 @@ export function formatFileInfoResult(r: ToolResult): string {
* 通用工具结果格式化(非文件类工具)。
* 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 {
return r.updated !== false
? t('aiTool.updatedTaskField', { id: 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 })
}
/**
* 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,
}
export function formatToolResult(tc: AiToolCallInfo): string {
const r = parseResult(tc.result)
if (!r) {
@@ -345,86 +470,8 @@ export function formatToolResult(tc: AiToolCallInfo): string {
}
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 })
}
case 'http_request':
return formatHttpResult(r)
default:
return formatJson(r)
}
const fn = TOOL_FORMATTERS[tc.name]
return fn ? fn(r) : formatJson(r)
}
/**
@@ -465,85 +512,110 @@ export function formatHttpResult(r: ToolResult): string {
* 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,
}
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 }) : ''
}
case 'http_request': {
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 })
}
default: return ''
}
const fn = TOOL_SUMMARIES[tc.name]
return fn ? fn(r) : ''
}