重构: CR-11 composables+ToolCard 健壮性 6 子项

⑦ useAiConversations JSON.parse 逐条 try/catch 容错(坏条降级不清空整对话);⑧ ToolCard 去 as any + argString 安全取字段;⑨ useAiSend approveToolCall 复用 findToolCall;⑩ useAiEvents 补 AiHeartbeat case 防穿透;⑪ approveHumanApproval 签名收敛(decisions:string[] + select_type 判单/多选)+ ProjectDetail 调用方同步(单选包数组/多选直传);⑫ ToolCard formatBytes/formatToolName/argString 兜底。批5 ww1mh6mry,vue-tsc 0
This commit is contained in:
2026-06-15 05:45:04 +08:00
parent 0e196ee86f
commit fddca9daf1
6 changed files with 68 additions and 31 deletions

View File

@@ -136,6 +136,7 @@ function shouldKeepOpen(tc: AiToolCallInfo): boolean {
} }
function formatToolName(name: string): string { function formatToolName(name: string): string {
if (!name) return ''
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
} }
@@ -200,6 +201,7 @@ function parseResult(result: unknown): ToolResult | null {
} }
function toolCategory(name: string): string { function toolCategory(name: string): string {
if (!name) return 'default'
if (name === 'read_file') return 'file-read' if (name === 'read_file') return 'file-read'
if (name === 'write_file') return 'file-write' if (name === 'write_file') return 'file-write'
if (name === 'list_directory') return 'dir' if (name === 'list_directory') return 'dir'
@@ -223,14 +225,23 @@ function shortPath(p: string): string {
return parts.length <= 2 ? p : '.../' + parts.slice(-2).join('/') return parts.length <= 2 ? p : '.../' + parts.slice(-2).join('/')
} }
/** 格式化字节数 */ /** 格式化字节数(兜底:undefined/null/负/NaN/Infinity 统一降级为 '0 B',防渲染异常) */
function formatBytes(bytes: number | undefined): string { function formatBytes(bytes: number | undefined | null): string {
if (!bytes) return '0 B' if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
if (bytes < 1024) return bytes + ' B' if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB' if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB' 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 ''
}
/** 通用工具结果格式化(非文件类工具) */ /** 通用工具结果格式化(非文件类工具) */
function formatToolResult(tc: AiToolCallInfo): string { function formatToolResult(tc: AiToolCallInfo): string {
const r = parseResult(tc.result) const r = parseResult(tc.result)
@@ -238,8 +249,11 @@ function formatToolResult(tc: AiToolCallInfo): string {
// 非 JSON(纯文本:审批占位/错误/拒绝)→ 原样显示,审批占位时补拟执行参数避免空白 // 非 JSON(纯文本:审批占位/错误/拒绝)→ 原样显示,审批占位时补拟执行参数避免空白
const raw = typeof tc.result === 'string' ? tc.result : '' const raw = typeof tc.result === 'string' ? tc.result : ''
if (raw && tc.args && typeof tc.args === 'object') { if (raw && tc.args && typeof tc.args === 'object') {
const a = tc.args as any const a = tc.args as Record<string, unknown>
const detail = a.title || a.name || a.path || '' const detail = typeof a.title === 'string' ? a.title
: typeof a.name === 'string' ? a.name
: typeof a.path === 'string' ? a.path
: ''
if (detail) return `${raw}${detail}` if (detail) return `${raw}${detail}`
} }
return raw return raw
@@ -325,8 +339,7 @@ function displayArgValue(arg: { key: string; val: unknown }): string {
* 解析项目名(复用 projectNameById,无则走 fallback),create_* 用固定文案。 * 解析项目名(复用 projectNameById,无则走 fallback),create_* 用固定文案。
*/ */
function toolDisplayName(tc: AiToolCallInfo): string { function toolDisplayName(tc: AiToolCallInfo): string {
const args = tc.args as any const p = argString(tc.args, 'path')
const p = args?.path as string | undefined
switch (tc.name) { switch (tc.name) {
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback') 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 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
@@ -336,7 +349,7 @@ function toolDisplayName(tc: AiToolCallInfo): string {
case 'purge_project': case 'purge_project':
case 'update_project': { case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name] const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? (args?.[idKey] as string | undefined) : '' const id = idKey ? argString(tc.args, idKey) : ''
const name = id ? projectNameById.value[id] : '' const name = id ? projectNameById.value[id] : ''
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix' const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback' const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'

View File

@@ -74,15 +74,27 @@ export async function switchConversation(id: string) {
content: m.content || '', content: m.content || '',
model: m.model, model: m.model,
timestamp: Date.now(), timestamp: Date.now(),
toolCalls: m.tool_calls?.map((tc: any) => ({ toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => {
id: tc.id, // 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填
name: tc.function?.name || '', let args: unknown = {}
args: typeof tc.function?.arguments === 'string' const rawArgs = tc.function?.arguments
? JSON.parse(tc.function.arguments || '{}') if (typeof rawArgs === 'string') {
: tc.function?.arguments || {}, try {
status: 'completed' as const, args = rawArgs ? JSON.parse(rawArgs) : {}
result: toolResultMap.get(tc.id), } catch {
})), args = {}
}
} else if (rawArgs && typeof rawArgs === 'object') {
args = rawArgs
}
return {
id: tc.id,
name: tc.function?.name || '',
args,
status: 'completed' as const,
result: toolResultMap.get(tc.id),
}
}),
})) }))
} catch { } catch {
state.messages = [] state.messages = []

View File

@@ -127,6 +127,10 @@ export function handleEvent(event: AiChatEvent) {
break break
} }
case 'AiHeartbeat':
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
break
case 'AiToolCallStarted': { case 'AiToolCallStarted': {
const info: AiToolCallInfo = { const info: AiToolCallInfo = {
id: event.id, id: event.id,

View File

@@ -13,7 +13,7 @@ import { aiApi } from '../../api'
import { useAppSettingsStore } from '../../stores/appSettings' import { useAppSettingsStore } from '../../stores/appSettings'
import { state } from '../../stores/ai' import { state } from '../../stores/ai'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { startListener } from './useAiEvents' import { startListener, findToolCall } from './useAiEvents'
import { nextMsgId } from './aiShared' import { nextMsgId } from './aiShared'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -81,9 +81,8 @@ async function sendMessage(text: string, skill?: string) {
/** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */ /** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */
async function approveToolCall(toolCallId: string, approved: boolean) { async function approveToolCall(toolCallId: string, approved: boolean) {
// 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected) // 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected)
const tc = state.messages // 复用 findToolCall(反向扫描)避免重复实现查找逻辑
.flatMap(m => m.toolCalls || []) const tc = findToolCall(toolCallId)
.find(t => t.id === toolCallId)
if (tc) tc.status = 'running' if (tc) tc.status = 'running'
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear, // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。 // 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。

View File

@@ -248,16 +248,25 @@ function createStore() {
/** /**
* 发送审批响应。 * 发送审批响应。
* - 单选(select_type=single 或缺省):传 decision 单值,decisions 留空; *
* - 多选(select_type=multiple):传 decisions 数组。 * 统一以 decisions 数组表达选择,按 pendingApproval.select_type 判定单/多选:
* F-260615-01: 新增 decisions/select_type 透传,与后端 IPC 签名对齐。 * - 单选(select_type='single' 或缺省):取 decisions[0] 作为后端 decision 单值;
* - 多选(select_type='multiple'):decisions 透传全量。
*
* 后端 IPC 需同时收 decision(单值,向后兼容)与 decisions(数组),此处按 select_type
* 自动分派,消除调用方"该传 decision 还是 decisions"的歧义。
* F-260615-01: decisions/select_type 透传,与后端 IPC 签名对齐。
*/ */
async function approveHumanApproval( async function approveHumanApproval(
decision: string, decisions: string[],
comment?: string, comment?: string,
decisions?: string[],
) { ) {
if (!state.pendingApproval) return if (!state.pendingApproval) return
const selectType = state.pendingApproval.select_type ?? 'single'
// 单选取首项(后端 decision 为单值);多选 decision 取首项作占位(后端按 decisions 走)
const decision = selectType === 'multiple'
? (decisions[0] ?? '')
: (decisions[0] ?? '')
try { try {
// 调用 IPC 命令发送审批响应 // 调用 IPC 命令发送审批响应
@@ -269,8 +278,8 @@ function createStore() {
decision, decision,
comment, comment,
options: state.pendingApproval.options ?? [], options: state.pendingApproval.options ?? [],
decisions: decisions ?? [], decisions,
select_type: state.pendingApproval.select_type ?? 'single', // B-260615-34:Tauri 2 IPC 不转 camelCase,须 snake_case 对齐后端 workflow.rs:211(同 invoke 其余字段 execution_id/node_id/decisions 均已 snake_case) select_type: selectType, // B-260615-34:Tauri 2 IPC 不转 camelCase,须 snake_case 对齐后端 workflow.rs:211
}) })
// 清除待审批状态 // 清除待审批状态

View File

@@ -407,7 +407,7 @@ async function handleImportDir() {
// ── 审批处理 ── // ── 审批处理 ──
async function handleApproval(decision: string) { async function handleApproval(decision: string) {
await store.approveHumanApproval(decision) await store.approveHumanApproval([decision])
showApprovalDialog.value = false showApprovalDialog.value = false
} }
@@ -417,8 +417,8 @@ const multiDecisions = ref<string[]>([])
async function handleApprovalMulti() { async function handleApprovalMulti() {
if (multiDecisions.value.length === 0) return if (multiDecisions.value.length === 0) return
// decision 传首项(向后兼容),decisions 透传全量 // 统一传 decisions 数组,store 按 select_type 分派单/多选
await store.approveHumanApproval(multiDecisions.value[0], undefined, [...multiDecisions.value]) await store.approveHumanApproval([...multiDecisions.value])
multiDecisions.value = [] multiDecisions.value = []
showApprovalDialog.value = false showApprovalDialog.value = false
} }