// 工具调用按类别合并成时间线的一行。新增类别必须补进 ToolKind 与两处 switch, // 否则 never 检查会在编译期失败。 import type { Part } from './contracts' export type ToolKind = 'read' | 'search' | 'exec' | 'write' | 'card' | 'delegate' | 'plan' | 'other' const READ = new Set(['fs_read_file', 'fs_list_dir', 'read_conversation', 'list_conversations']) const SEARCH = new Set(['knowledge_search', 'tavily_search', 'web_search', 'history_search']) const EXEC = new Set(['run_shell', 'run_python']) const WRITE = new Set(['write_artifact', 'register_artifact', 'fs_write_file']) const CARD = new Set(['submit_card', 'card_begin', 'card_append', 'card_commit']) const DELEGATE = new Set(['delegate_task', 'squad_orchestrate']) const PLAN = new Set(['update_plan', 'update_todos']) export function toolKind(name: string): ToolKind { if (READ.has(name)) return 'read' if (SEARCH.has(name)) return 'search' if (EXEC.has(name)) return 'exec' if (WRITE.has(name)) return 'write' if (CARD.has(name)) return 'card' if (DELEGATE.has(name)) return 'delegate' if (PLAN.has(name)) return 'plan' return 'other' } function argOf(raw: string | undefined, keys: string[]): string { if (!raw) return '' try { const v = JSON.parse(raw) as Record for (const k of keys) { const x = v[k] if (typeof x === 'string' && x.trim()) return x.trim() } } catch { return '' } return '' } function firstLine(s: string): string { const line = s.split('\n').map(x => x.trim()).find(Boolean) ?? '' return line.length > 80 ? `${line.slice(0, 80)}…` : line } /** 从工具参数里取出给用户看的一行:路径、查询词或脚本首行。 */ export function toolHeadline(name: string, args?: string): string { const kind = toolKind(name) switch (kind) { case 'read': return argOf(args, ['path', 'file', 'conversationId']) || name case 'search': return argOf(args, ['query', 'q']) || name case 'exec': return firstLine(argOf(args, ['script', 'command', 'cmd'])) || name case 'write': return argOf(args, ['path', 'name', 'filename']) || name case 'card': return argOf(args, ['title']) || name case 'delegate': return argOf(args, ['task', 'goal', 'name']) || name case 'plan': return argOf(args, ['title']) || name case 'other': return name default: { const exhaustive: never = kind return exhaustive } } } export function toolGroupLabel(kind: ToolKind, count: number, running: boolean): string { switch (kind) { case 'read': return `查阅 · ${count} 个文件` case 'search': return `搜索 · ${count} 次` case 'exec': return running ? `运行命令 · ${count} 条` : `已运行命令 · ${count} 条` case 'write': return `写入文件 · ${count} 个` case 'card': return `卡片 · ${count} 张` case 'delegate': return `委派 · ${count} 个任务` case 'plan': return `更新计划 · ${count} 次` case 'other': return `工具 · ${count} 次` default: { const exhaustive: never = kind return exhaustive } } } export interface ToolGroupItem { name: string headline: string status?: 'ok' | 'error' summary?: string running: boolean } export type TimelineEntry = | { kind: 'thinking'; text: string; durationMs?: number; active: boolean } | { kind: 'text'; text: string } | { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] } | { kind: 'other'; part: Part } /** 把时间线部件收成行:相邻同类工具合并,思考是否仍在进行由 live 与后续部件决定。 */ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] { const out: TimelineEntry[] = [] let i = 0 while (i < parts.length) { const p = parts[i] if (p.type === 'tool_call') { const kind = toolKind(p.toolCall?.name ?? '') const items: ToolGroupItem[] = [] while (i < parts.length && parts[i].type === 'tool_call' && toolKind(parts[i].toolCall?.name ?? '') === kind) { const call = parts[i] i++ let res: Part | undefined if (parts[i]?.type === 'tool_result') { res = parts[i] i++ } const status = res?.toolResult?.status items.push({ name: call.toolCall?.name ?? '工具', headline: toolHeadline(call.toolCall?.name ?? '', call.toolCall?.arguments), status: status === 'ok' || status === 'error' ? status : undefined, summary: res?.toolResult?.summary, running: live && !res, }) } out.push({ kind: 'tools', toolKind: kind, items }) continue } if (p.type === 'tool_result') { i++; continue } if (p.type === 'thinking') { out.push({ kind: 'thinking', text: p.text ?? '', durationMs: p.durationMs, active: false }) i++ continue } if (p.type === 'text') { out.push({ kind: 'text', text: p.text ?? '' }) i++ continue } out.push({ kind: 'other', part: p }) i++ } if (live) { const idx = out.reduce((acc, e, n) => (e.kind === 'thinking' ? n : acc), -1) const entry = idx >= 0 ? out[idx] : undefined if (entry && entry.kind === 'thinking' && out.slice(idx + 1).every(e => e.kind === 'thinking')) { entry.active = true } } return out }