Files
Agent-System-Frontend/src/toolkind.ts
T
weichenandCursor 0ed8ad32f1 Show assistant work as a timeline and preview files in tabs
Why: Process output folded on every text delta, successful tool
results disappeared after refresh, and generated files stayed plain
text instead of a card that opens a closable preview.

Evidence: npx tsc -b --noEmit (clean); npx vitest run (51 passed).

Impact: Live work stays expanded until the run ends, then folds to
one worked-time line. File cards open a per-conversation preview pane.
docx renders page by page, with mammoth if that fails.

Refs: feature/chat-timeline-preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 06:15:44 +00:00

147 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工具调用按类别合并成时间线的一行。新增类别必须补进 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<string, unknown>
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
}