diff --git a/src/api.ts b/src/api.ts index 3e17570..5878845 100644 --- a/src/api.ts +++ b/src/api.ts @@ -137,7 +137,7 @@ export function connectEvents(conversationId: string, since: number, handlers: S } es.onmessage = ev => handle('message', ev.data) // 同时监听具名事件与默认(服务端 event: 字段)。 - const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase', + const types = ['message.delta', 'thinking.delta', 'tool.call.delta', 'tool.call', 'tool.output', 'tool.result', 'run.phase', 'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress', 'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error', 'card.created', 'card.updated', 'card.status', 'card.resolved'] diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 4b7f21c..c4d7b1c 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -1,17 +1,17 @@ // 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。 // 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(
已禁);工具调用块走 // ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。 -import { Icon, Avatar, BrandMark, Collapsible } from '@ui' +import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen } from '@ui' import { useEffect, useMemo, useRef, useState } from 'react' import { marked } from 'marked' import { api } from '../api' import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store' import { scrollRatio, scrollTopForRatio } from '../scrollsync' -import { liveToMessage, showProcessInline } from '../live' +import { liveToMessage, livePhaseLabel, showProcessInline } from '../live' import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui' import { docBridge } from './Workbench' -import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, workedLabel } from '../messageparts' +import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts' import { ProcessTimeline } from './ProcessTimeline' function hasFiles(dt: DataTransfer | null): boolean { @@ -63,8 +63,21 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) { ) } - case 'error': - return
{part.error?.code}: {part.error?.message}
+ case 'error': { + const view = humanError(part.error?.code, part.error?.message) + return ( +
+
{view.title}
+ {view.detail && ( + ▸详情}> +
{view.detail}
+
+ )} + +
+ ) + } default: return null } @@ -105,7 +118,7 @@ function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string } // 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。 -function DeliverableCard({ artifact }: { artifact: { artifactId: string; name: string; kind?: string } }) { +function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) { const [menu, setMenu] = useState(false) const art = findArtifact(artifact.artifactId) const name = artifact.name || art?.name || '文件' @@ -118,7 +131,7 @@ function DeliverableCard({ artifact }: { artifact: { artifactId: string; name: s {name} - {kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'} + {kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{updates > 0 ? ` · 已更新 ${updates} 次` : ''}{art ? '' : ' · 生成中'} @@ -149,6 +162,15 @@ function citedArtifactCards(parts: Part[]): Artifact[] { } // 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。 +function useLiveClock(active: boolean) { + const [, tick] = useState(0) + useEffect(() => { + if (!active) return + const t = window.setInterval(() => tick(n => n + 1), 1000) + return () => window.clearInterval(t) + }, [active]) +} + function useElapsed(startedAt: number): string { const [, tick] = useState(0) useEffect(() => { @@ -166,29 +188,53 @@ function LiveBar({ live }: { live: LiveState }) { return ( - {live.phase} + {livePhaseLabel(live)} 已工作 {elapsed} ) } +// 用户在运行中手动折过的 run:结束后沿用这次选择,不强制再收起。 +const touchedProcess = new Set() + function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) { const autoCollapse = useStore(s => s.ui.autoCollapseProcess) const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined)) const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined)) const parts = msg.parts ?? [] - const split = useMemo(() => splitTimeline(parts), [parts]) - // 运行中时间线一直展开。终轮确认没有后续工具调用之后,才按偏好收成「已工作 X」。 + useLiveClock(!!live) + const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl' + const split = useMemo(() => splitTimeline(parts, finished), [parts, finished]) const showProcess = showProcessInline(live ?? null, autoCollapse) + const memKey = `process:${msg.runId || msg.id}` + const touched = !!(msg.runId && touchedProcess.has(msg.runId)) + const [open, setOpen] = useState(() => { + if (live && !touched) return true + if (touched) return getPersistedOpen(memKey) + return showProcess + }) + useEffect(() => { + if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true) + }, [live, msg.runId]) const extras = citedArtifactCards(split.final) + const cards = dedupeDeliverables(split.final) const lastText = (() => { for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i return -1 })() + const streamingText = !!(live && live.phase === 'responding' && lastText >= 0 + && !live.items.some(it => it.kind === 'tool' && it.state !== 'done')) + const toolCount = split.timeline.filter(p => p.type === 'tool_call').length + const headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt) const timeline = ( } /> ) + const toggleProcess = (next: boolean) => { + if (msg.runId) touchedProcess.add(msg.runId) + setPersistedOpen(memKey, next) + setOpen(next) + } return (
@@ -196,23 +242,24 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null 助手 {live && }
- {split.timeline.length > 0 && (showProcess - ?
{timeline}
- : ( -
- - ▸ - {workedLabel(run?.createdAt, run?.endedAt)} - }> -
{timeline}
-
-
- ))} - {split.final.map((part, i) => ( -
- + {split.timeline.length > 0 && ( +
+ + ▸ + {headline} + }> +
{timeline}
+
+ )} + {split.final.map((part, i) => part.type === 'artifact_ref' ? null : ( +
+ +
+ ))} + {cards.map(card => ( + ))} {extras.map(art => ( @@ -379,6 +426,8 @@ export function Conversation() { const [attachmentNames, setAttachmentNames] = useState([]) const [dragDepth, setDragDepth] = useState(0) const bodyRef = useRef(null) + const stickRef = useRef(true) + const [showJump, setShowJump] = useState(false) artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a])) cardCache = Object.fromEntries(cards.map(c => [c.id, c])) @@ -386,6 +435,7 @@ export function Conversation() { const active = activeRun(runs) useEffect(() => { + if (!stickRef.current) return bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }) }, [messages.length, live?.items, live?.phase, activeHitl.length]) @@ -457,6 +507,10 @@ export function Conversation() { onScroll={() => { const src = bodyRef.current if (!src) return + const dist = src.scrollHeight - src.scrollTop - src.clientHeight + const stick = dist < 80 + stickRef.current = stick + setShowJump(prev => (prev === !stick ? prev : !stick)) const ratio = scrollRatio(src) if (ratio === null) return const dst = document.querySelector('[data-scroll-sync="inbox"]') @@ -465,7 +519,7 @@ export function Conversation() { if (top === null) return dst.scrollTop = top }}> - {messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => { + {foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => { if (m.role === 'user') { // 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。 const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm') @@ -496,6 +550,13 @@ export function Conversation() { {runtimeMock &&
mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help
}
}
+ {showJump && ( + + )}
diff --git a/src/components/ProcessTimeline.tsx b/src/components/ProcessTimeline.tsx index 7f1c31a..b906361 100644 --- a/src/components/ProcessTimeline.tsx +++ b/src/components/ProcessTimeline.tsx @@ -1,10 +1,10 @@ // 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开; // 终轮结束后由调用方套进「已工作 X」折叠。 import type { ReactNode } from 'react' -import { Collapsible } from '@ui' +import { Collapsible, ToolBlock } from '@ui' import type { Part } from '../contracts' -import { formatDuration } from '../messageparts' -import { timelineEntries, toolGroupLabel } from '../toolkind' +import { formatDuration, humanSize } from '../messageparts' +import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind' export function ProcessTimeline({ parts, live, idKey, renderPart }: { parts: Part[] @@ -33,6 +33,9 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: { if (row.kind === 'text') { return
{renderPart({ type: 'text', text: row.text }, false)}
} + if (row.kind === 'toolblock') { + return + } if (row.kind === 'tools') { const running = row.items.some(it => it.running) return ( @@ -55,3 +58,33 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
) } + +function sizeLabel(n: number): string { + if (n < 1024) return `${n} 字符` + return humanSize(n) +} + +function ToolCallBlock({ item }: { item: ToolGroupItem }) { + const composing = item.phase === 'composing' + const tail = item.running ? toolTail(item.arguments, item.output) : '' + const dur = !item.running ? formatDuration(item.durationMs) : '' + const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline) + return ( +
+ + {item.arguments &&
{item.arguments}
} + {item.output &&
{item.output}
} +
+ {tail &&
{tail}
} +
+ ) +} diff --git a/src/contracts.ts b/src/contracts.ts index 46572ba..425692e 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -80,7 +80,8 @@ export interface TodoList { // ---- 流式事件 ---- export type StreamEventType = - | 'message.delta' | 'thinking.delta' | 'tool.call' | 'tool.result' + | 'message.delta' | 'thinking.delta' + | 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result' | 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update' | 'agent.spawn' | 'agent.progress' | 'agent.result' | 'artifact.created' | 'artifact.updated' @@ -94,6 +95,9 @@ export interface StreamEvent

{ // payload 形状(按 type 判别;未知字段忽略) export interface MessageDeltaPayload { index: number; text: string } +export interface ToolCallDeltaPayload { index: number; callId?: string; name?: string; delta: string } +export interface ToolOutputPayload { callId: string; stream: 'stdout' | 'stderr'; text: string } +export interface ToolResultPayload { callId: string; status: string; summary?: string; truncated?: boolean; durationMs?: number } export interface AgentSpawnPayload { agentRun: ID; name: string; task: string } export interface AgentProgressPayload { agentRun: ID; note?: string; phase?: RunPhase } export interface AgentResultPayload { agentRun: ID; status: RunStatus; summary?: string } diff --git a/src/live.ts b/src/live.ts index 2b59be1..ff94eb8 100644 --- a/src/live.ts +++ b/src/live.ts @@ -1,21 +1,40 @@ // live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text // 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库 // 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。 -// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定, -// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区, -// 无需等下一轮文本段开启。 -// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议 -// 携带轮次归属才能消除;现行事件流无法区分)。 +// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。 +// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。 import type { Message, Part } from './contracts' import type { LiveState } from './store' +export type ToolLiveState = 'composing' | 'running' | 'done' + // 到达序部件流的元素。 export type LiveItem = | { kind: 'thinking'; text: string; startedAt: number; endedAt: number } | { kind: 'text'; index: number; text: string } - | { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string } + | { + kind: 'tool' + callId?: string + index?: number + name: string + args: string + state: ToolLiveState + startedAt: number + endedAt: number + output: string + status?: string + summary?: string + } | { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } } +export type LiveTool = Extract + +const OUTPUT_TAIL = 4096 + +function isTool(it: LiveItem): it is LiveTool { + return it.kind === 'tool' +} + // applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。 // now 供测试固定首尾时间;缺省用墙上时钟,durationMs = endedAt - startedAt。 export function applyThinkingDelta(items: LiveItem[], delta: string, now = Date.now()): LiveItem[] { @@ -39,34 +58,109 @@ export function applyTextDelta(items: LiveItem[], index: number, delta: string): return [...items, { kind: 'text', index, text: delta }] } -export function applyToolCall(items: LiveItem[], name: string, callId?: string): LiveItem[] { - return [...items, { kind: 'tool', callId, name }] -} - -// applyArtifactCreated 追加工件引用(artifact.created 事件)。投影时作为 -// 交付物钉在消息尾部(内容部件,不进过程折叠)——与落库序同构。 -export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] { - return [...items, { kind: 'artifact', artifact }] -} - -// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具—— -// 与落库的尾部配对语义一致)。 -export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string): LiveItem[] { +// applyToolCallDelta 并入正在编写的参数。同 index(或同 callId)的编写中条目追加,否则新开一条。 +export function applyToolCallDelta(items: LiveItem[], index: number, delta: string, callId?: string, name?: string, now = Date.now()): LiveItem[] { for (let i = items.length - 1; i >= 0; i--) { const it = items[i] - if (it.kind !== 'tool' || it.status !== undefined) continue - if (callId && it.callId && it.callId !== callId) continue + if (!isTool(it) || it.state !== 'composing') continue + if (it.index !== index && !(callId && it.callId === callId)) continue const out = items.slice() - out[i] = { kind: 'tool', callId: it.callId, name: it.name, status, summary } + out[i] = { + ...it, + args: it.args + delta, + callId: callId || it.callId, + name: name || it.name, + endedAt: now, + } + return out + } + return [...items, { + kind: 'tool', index, callId, name: name || '工具', args: delta, + state: 'composing', startedAt: now, endedAt: now, output: '', + }] +} + +// applyToolCall 把一条调用标成运行中。优先按 callId 接上编写中的条目,否则接第一条编写中的条目。 +export function applyToolCall(items: LiveItem[], name: string, callId?: string, args?: string, now = 0): LiveItem[] { + let idx = -1 + if (callId) { + for (let i = 0; i < items.length; i++) { + const it = items[i] + if (isTool(it) && it.state === 'composing' && it.callId === callId) { idx = i; break } + } + } + if (idx < 0) { + for (let i = 0; i < items.length; i++) { + const it = items[i] + if (isTool(it) && it.state === 'composing') { idx = i; break } + } + } + if (idx >= 0) { + const it = items[idx] as LiveTool + const out = items.slice() + out[idx] = { + ...it, + name: name || it.name, + callId: callId || it.callId, + args: args || it.args, + state: 'running', + startedAt: now || it.startedAt, + endedAt: now, + } + return out + } + return [...items, { + kind: 'tool', callId, name, args: args ?? '', state: 'running', + startedAt: now, endedAt: now, output: '', + }] +} + +// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。 +export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i] + if (!isTool(it) || it.state === 'done') continue + if (callId && it.callId && it.callId !== callId) continue + let output = it.output + text + if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL) + const out = items.slice() + out[i] = { ...it, output } return out } return items } -// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息, -// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。 +// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具)。 +export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string, now = 0): LiveItem[] { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i] + if (!isTool(it) || it.state === 'done') continue + if (callId && it.callId && it.callId !== callId) continue + const out = items.slice() + out[i] = { ...it, callId: it.callId, name: it.name, status, summary, state: 'done', endedAt: now || it.endedAt } + return out + } + return items +} + +export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] { + return [...items, { kind: 'artifact', artifact }] +} + +export function applyArtifactUpdated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] { + const out = items.slice() + for (let i = out.length - 1; i >= 0; i--) { + const it = out[i] + if (it.kind === 'artifact' && it.artifact.artifactId === artifact.artifactId) { + out[i] = { kind: 'artifact', artifact: { ...it.artifact, ...artifact } } + return out + } + } + return [...out, { kind: 'artifact', artifact }] +} + +// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息。 export function liveToMessage(live: LiveState): Message { - // 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。 const textProcess = new Map() for (let i = 0; i < live.items.length; i++) { if (live.items[i].kind !== 'text') continue @@ -85,14 +179,24 @@ export function liveToMessage(live: LiveState): Message { ...(durationMs > 0 ? { durationMs } : {}), }) } else if (it.kind === 'tool') { - parts.push({ type: 'tool_call', process: true, toolCall: { id: it.callId ?? '', name: it.name } }) - if (it.status !== undefined) { - parts.push({ type: 'tool_result', process: true, toolCall: { id: it.callId ?? '', name: it.name }, - toolResult: { callId: it.callId ?? '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } }) + const durationMs = it.state === 'done' ? it.endedAt - it.startedAt : 0 + const call = { + id: it.callId ?? '', + name: it.name, + ...(it.args ? { arguments: it.args } : {}), + ...(durationMs > 0 ? { durationMs } : {}), + } + parts.push({ + type: 'tool_call', process: true, toolCall: call, + toolPhase: it.state, ...(it.output ? { toolOutput: it.output } : {}), + } as Part) + if (it.state === 'done') { + parts.push({ + type: 'tool_result', process: true, toolCall: { id: call.id, name: call.name }, + toolResult: { callId: call.id, status: it.status === 'error' ? 'error' : 'ok', summary: it.summary }, + }) } } else if (it.kind === 'artifact') { - // 交付物卡片:内容部件,投影钉尾(落库侧 llm.go 同序——最终正文之后), - // 流式期间即可在消息底部看到"文件已生成"卡片。 deliverables.push({ type: 'artifact_ref', artifact: it.artifact }) } else { parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined }) @@ -102,8 +206,27 @@ export function liveToMessage(live: LiveState): Message { return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } } -// showProcessInline:运行中时间线始终展开(不能靠 responding 提前折,那一轮文字 -// 后面还可能再调用工具)。run 结束后随「自动折叠过程」偏好收成一行。 +// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。 +export function livePhaseLabel(live: LiveState, now = Date.now()): string { + let last: LiveItem | undefined + for (let i = live.items.length - 1; i >= 0; i--) { + if (live.items[i].kind !== 'artifact') { last = live.items[i]; break } + } + if (!last || last.kind === 'thinking') return '思考中' + if (last.kind === 'tool') { + if (last.state === 'composing') return `正在编写 ${last.name}` + if (last.state === 'running') { + const s = Math.max(0, Math.floor((now - last.startedAt) / 1000)) + const m = Math.floor(s / 60) + const clock = m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒` + return `正在运行 ${last.name} · ${clock}` + } + return '回复中' + } + return '回复中' +} + +// showProcessInline:运行中时间线始终展开。run 结束后随「自动折叠过程」偏好收成一行。 export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean { return live != null || !autoCollapse } diff --git a/src/messageparts.ts b/src/messageparts.ts index f20b0a8..b571e4d 100644 --- a/src/messageparts.ts +++ b/src/messageparts.ts @@ -7,12 +7,90 @@ export interface TimelineSplit { final: Part[] } -/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。 */ -export function splitTimeline(parts: Part[]): TimelineSplit { +/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。 + * promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */ +export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit { let last = -1 for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i if (last < 0) return { timeline: [], final: parts } - return { timeline: parts.slice(0, last + 1), final: parts.slice(last + 1) } + const timeline = parts.slice(0, last + 1) + const final = parts.slice(last + 1) + if (!promoteLastText || final.some(p => p.type === 'text' && (p.text ?? '').trim())) return { timeline, final } + let textAt = -1 + for (let i = timeline.length - 1; i >= 0; i--) { + if (timeline[i].type === 'text' && (timeline[i].text ?? '').trim()) { textAt = i; break } + } + if (textAt < 0) return { timeline, final } + const lifted = { ...timeline[textAt], process: undefined } + return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] } +} + +/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */ +export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable; updates: number }[] { + const order: string[] = [] + const byName = new Map; count: number }>() + for (const p of parts) { + if (p.type !== 'artifact_ref' || !p.artifact) continue + const name = p.artifact.name || p.artifact.artifactId + const cur = byName.get(name) + if (!cur) { + order.push(name) + byName.set(name, { artifact: p.artifact, count: 1 }) + } else { + cur.artifact = p.artifact + cur.count++ + } + } + return order.map(name => { + const cur = byName.get(name)! + return { artifact: cur.artifact, updates: cur.count - 1 } + }) +} + +/** 把单独成条的错误消息并回同一 run 的助手消息。没有正文的错误条保留原位。 */ +export function foldRunErrors(messages: T[]): T[] { + const extras = new Map() + for (const m of messages) { + if (m.role !== 'assistant' || !m.runId) continue + if (m.parts.length > 0 && m.parts.every(p => p.type === 'error')) { + extras.set(m.runId, [...(extras.get(m.runId) ?? []), ...m.parts]) + } + } + const used = new Set() + const out: T[] = [] + for (const m of messages) { + if (m.role === 'assistant' && m.runId && extras.has(m.runId) && m.parts.every(p => p.type === 'error')) { + const hasBody = messages.some(o => o !== m && o.runId === m.runId && o.role === 'assistant' && o.parts.some(p => p.type !== 'error')) + if (hasBody) continue + } + if (m.role === 'assistant' && m.runId && extras.has(m.runId) && m.parts.some(p => p.type !== 'error') && !used.has(m.runId)) { + used.add(m.runId) + out.push({ ...m, parts: [...m.parts, ...extras.get(m.runId)!] }) + continue + } + out.push(m) + } + return out +} + +/** 把运行错误说成人话。detail 保留原始码,供展开查看。 */ +export function humanError(code?: string, message?: string): { title: string; detail: string } { + const msg = (message ?? '').trim() + const detail = [code, msg].filter(Boolean).join(': ') + if (code === 'run.cancelled' || msg === '已取消') return { title: '已取消', detail } + const stripped = msg.replace(/^budget:\s*/, '') + if (msg.startsWith('budget:') || /max turns|maxTurns|tool calls/i.test(msg)) { + return { title: `已停止:${stripped}`, detail } + } + return { title: msg ? `已停止:${msg}` : '已停止', detail } +} + +/** 终态摘要:已工作时长,以及工具次数和文件数。 */ +export function processSummary(toolCalls: number, files: number, createdAt?: string, endedAt?: string): string { + const bits = [workedLabel(createdAt, endedAt)] + if (toolCalls > 0) bits.push(`${toolCalls} 次工具调用`) + if (files > 0) bits.push(`${files} 个文件`) + return bits.join(' · ') } const ART_ID = /art_[0-9a-f]{12}/g diff --git a/src/store.ts b/src/store.ts index 314f7d5..5747bdc 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,7 +2,7 @@ // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 import { useSyncExternalStore } from 'react' import { api, connectEvents } from './api' -import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live' +import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, type LiveItem } from './live' import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs' import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox' import { flashElement } from './scrollsync' @@ -354,14 +354,24 @@ export const store = { set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) }) break } + case 'tool.call.delta': { + const p = e.payload as { index: number; callId?: string; name?: string; delta: string } + set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCallDelta(l.items, p.index, p.delta, p.callId, p.name) }) }) + break + } case 'tool.call': { - const p = e.payload as { callId: string; name: string } - set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId) }) }) + const p = e.payload as { callId: string; name: string; arguments?: string } + set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId, p.arguments, Date.now()) }) }) + break + } + case 'tool.output': { + const p = e.payload as { callId?: string; text: string } + set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolOutput(l.items, p.callId, p.text) }) }) break } case 'tool.result': { const p = e.payload as { callId: string; status: string; summary?: string } - set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary) }) }) + set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary, Date.now()) }) }) break } case 'run.phase': { @@ -421,11 +431,13 @@ export const store = { set({ cards }) break } - case 'artifact.created': { + case 'artifact.created': + case 'artifact.updated': { const p = e.payload as { artifact?: { id: string; name: string; kind?: string } } if (p?.artifact) { + const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind } set({ live: touchLive(e.runId ?? '', l => { - l.items = applyArtifactCreated(l.items, { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind }) + l.items = e.type === 'artifact.updated' ? applyArtifactUpdated(l.items, ref) : applyArtifactCreated(l.items, ref) }) }) } if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId) diff --git a/src/styles.css b/src/styles.css index 963105b..af403ad 100644 --- a/src/styles.css +++ b/src/styles.css @@ -92,6 +92,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); border-radius: var(--radius-lg) var(--radius-lg) 0 0; } .chat-title { font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); } +.jump-bottom { + position: absolute; left: 50%; bottom: 92px; transform: translateX(-50%); z-index: 4; + border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); + border-radius: var(--radius-full); padding: 6px 14px; font-size: var(--fs-ui-sm); + box-shadow: var(--shadow-md); cursor: pointer; +} .drop-veil { position: absolute; inset: 8px; z-index: 5; pointer-events: none; display: flex; align-items: center; justify-content: center; @@ -145,6 +151,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; } .part-text a { color: var(--accent); } .part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; } +.part-error-detail { margin-top: 8px; color: var(--text-dim); font-size: var(--fs-ui-sm); } +.tool-preview, .tool-live-preview { + margin: 0; white-space: pre-wrap; font-family: var(--mono); font-size: var(--fs-ui-sm); + color: var(--text-dim); max-height: 8em; overflow: auto; +} +.tool-live-preview { margin-top: 6px; } .artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: var(--fs-ui-caption); margin: 2px 0; } .artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; } .artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/src/toolkind.ts b/src/toolkind.ts index d16ef5e..e3686b2 100644 --- a/src/toolkind.ts +++ b/src/toolkind.ts @@ -37,6 +37,14 @@ function argOf(raw: string | undefined, keys: string[]): string { return '' } +/** 编写时看脚本尾部,运行后有输出则改看输出尾部。 */ +export function toolTail(args?: string, output?: string): string { + const script = argOf(args, ['script', 'command', 'cmd']) + const body = (output && output.trim()) ? output : (script || args || '') + const lines = body.split('\n').filter((line, i, all) => line.trim() || i === all.length - 1) + return lines.slice(-6).join('\n') +} + 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 @@ -79,8 +87,13 @@ export function toolGroupLabel(kind: ToolKind, count: number, running: boolean): } export interface ToolGroupItem { + id: string name: string headline: string + arguments?: string + output?: string + phase?: 'composing' | 'running' | 'done' + durationMs?: number status?: 'ok' | 'error' summary?: string running: boolean @@ -90,8 +103,34 @@ export type TimelineEntry = | { kind: 'thinking'; text: string; durationMs?: number; active: boolean } | { kind: 'text'; text: string } | { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] } + | { kind: 'toolblock'; toolKind: ToolKind; item: ToolGroupItem } | { kind: 'other'; part: Part } +const SOLO: ReadonlySet = new Set(['exec', 'write', 'delegate']) + +interface ToolFace { + toolPhase?: 'composing' | 'running' | 'done' + toolOutput?: string +} + +function toolItem(call: Part, res: Part | undefined, live: boolean): ToolGroupItem { + const face = call as Part & ToolFace + const status = res?.toolResult?.status + const phase = face.toolPhase ?? (live && !res ? 'running' : res ? 'done' : undefined) + return { + id: call.toolCall?.id || call.toolCall?.name || 'tool', + name: call.toolCall?.name ?? '工具', + headline: toolHeadline(call.toolCall?.name ?? '', call.toolCall?.arguments), + arguments: call.toolCall?.arguments, + output: face.toolOutput, + phase, + durationMs: call.toolCall?.durationMs, + status: status === 'ok' || status === 'error' ? status : undefined, + summary: res?.toolResult?.summary, + running: phase === 'composing' || phase === 'running', + } +} + /** 把时间线部件收成行:相邻同类工具合并,思考是否仍在进行由 live 与后续部件决定。 */ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] { const out: TimelineEntry[] = [] @@ -100,23 +139,21 @@ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] { const p = parts[i] if (p.type === 'tool_call') { const kind = toolKind(p.toolCall?.name ?? '') + if (SOLO.has(kind)) { + const call = parts[i] + i++ + let res: Part | undefined + if (parts[i]?.type === 'tool_result') { res = parts[i]; i++ } + out.push({ kind: 'toolblock', toolKind: kind, item: toolItem(call, res, live) }) + continue + } 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, - }) + if (parts[i]?.type === 'tool_result') { res = parts[i]; i++ } + items.push(toolItem(call, res, live)) } out.push({ kind: 'tools', toolKind: kind, items }) continue diff --git a/test/live.test.ts b/test/live.test.ts index 283b805..6853bc9 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -2,8 +2,8 @@ // 轮次穿插同构、过程区折叠谓词。 import { describe, it, expect } from 'vitest' import { - applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, - liveToMessage, showProcessInline, type LiveItem, + applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, + liveToMessage, livePhaseLabel, showProcessInline, type LiveItem, } from '../src/live' import type { LiveState } from '../src/store' @@ -63,12 +63,25 @@ describe('applyThinkingDelta / applyTextDelta', () => { items = applyToolCall(items, 'b', 'cb') items = applyToolResult(items, 'cb', 'ok', '结果') expect(items).toEqual([ - { kind: 'tool', callId: 'ca', name: 'a' }, - { kind: 'tool', callId: 'cb', name: 'b', status: 'ok', summary: '结果' }, + { kind: 'tool', callId: 'ca', name: 'a', args: '', state: 'running', startedAt: 0, endedAt: 0, output: '' }, + { kind: 'tool', callId: 'cb', name: 'b', args: '', state: 'done', startedAt: 0, endedAt: 0, output: '', status: 'ok', summary: '结果' }, ]) - // 无 callId 的事件回落到尾部配对。 items = applyToolResult(items, undefined, 'ok') - expect(items[0]).toEqual({ kind: 'tool', callId: 'ca', name: 'a', status: 'ok' }) + expect(items[0]).toMatchObject({ kind: 'tool', callId: 'ca', name: 'a', state: 'done', status: 'ok' }) + }) + it('walks a tool from composing to running to done, and marks the narration as process', () => { + let items: LiveItem[] = applyTextDelta([], 0, '我来生成文档。') + items = applyToolCallDelta(items, 0, '{"script":', 'c1', 'run_python', 1000) + items = applyToolCallDelta(items, 0, '"print(1)"}', undefined, undefined, 1100) + const composing = liveToMessage(live({ items })) + expect(composing.parts[0].process).toBe(true) + expect(composing.parts[1]).toMatchObject({ type: 'tool_call', toolPhase: 'composing', toolCall: { name: 'run_python', arguments: '{"script":"print(1)"}' } }) + items = applyToolCall(items, 'run_python', 'c1', undefined, 2000) + items = applyToolOutput(items, 'c1', 'hello\n') + items = applyToolResult(items, 'c1', 'ok', 'exit=0', 3500) + const done = liveToMessage(live({ items })) + expect(done.parts[1]).toMatchObject({ toolPhase: 'done', toolOutput: 'hello\n', toolCall: { durationMs: 1500 } }) + expect(done.parts[2]).toMatchObject({ type: 'tool_result', toolResult: { summary: 'exit=0' } }) }) }) @@ -78,7 +91,7 @@ describe('liveToMessage', () => { expect(msg.parts).toEqual([ { type: 'thinking', text: '先想想一下', process: true }, { type: 'text', text: '我先更新待办。', process: true }, - { type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'update_todos' } }, + { type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'update_todos' }, toolPhase: 'done' }, { type: 'tool_result', process: true, toolCall: { id: 'c1', name: 'update_todos' }, toolResult: { callId: 'c1', status: 'ok', summary: '已更新' } }, { type: 'thinking', text: '再想想', process: true }, @@ -137,6 +150,20 @@ describe('liveToMessage', () => { }) }) +describe('livePhaseLabel', () => { + it('names the current activity in Chinese', () => { + let items: LiveItem[] = applyThinkingDelta([], '想', 0) + expect(livePhaseLabel(live({ items }), 0)).toBe('思考中') + items = applyToolCallDelta(items, 0, '{}', 'c', 'run_python', 0) + expect(livePhaseLabel(live({ items }), 0)).toBe('正在编写 run_python') + items = applyToolCall(items, 'run_python', 'c', undefined, 1000) + expect(livePhaseLabel(live({ items }), 4000)).toBe('正在运行 run_python · 3 秒') + items = applyToolResult(items, 'c', 'ok', 'done', 4000) + items = applyTextDelta(items, 1, '好了') + expect(livePhaseLabel(live({ items }), 4000)).toBe('回复中') + }) +}) + describe('showProcessInline', () => { it('stays expanded for the whole live run, including the responding phase', () => { expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true) diff --git a/test/messageparts.test.ts b/test/messageparts.test.ts index 3098d0f..7fa652c 100644 --- a/test/messageparts.test.ts +++ b/test/messageparts.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel } from '../src/messageparts' +import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts' import type { Part } from '../src/contracts' function p(partial: Partial & { type: Part['type'] }): Part { @@ -32,6 +32,20 @@ describe('splitTimeline', () => { expect(split.final.map(x => x.text)).toEqual(['最终']) }) + it('lifts the last narration when a finished run has no final text', () => { + const parts: Part[] = [ + p({ type: 'text', text: '先写一版', process: true }), + p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }), + p({ type: 'text', text: '纪要已经整理好', process: true }), + p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }), + ] + const split = splitTimeline(parts, true) + expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好']) + expect(split.final[0].process).toBeUndefined() + expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call']) + expect(splitTimeline(parts).final).toEqual([]) + }) + it('puts a message with no process parts entirely in the final section', () => { const parts = [p({ type: 'text', text: 'hi' })] expect(splitTimeline(parts).timeline).toEqual([]) @@ -54,6 +68,32 @@ describe('artifact ids in reply text', () => { }) }) +describe('deliverables, errors, and the process summary', () => { + it('keeps the latest card for a repeated file name', () => { + const parts: Part[] = [ + p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '精美版.docx' } }), + p({ type: 'artifact_ref', artifact: { artifactId: 'a2', name: '精美版.docx' } }), + p({ type: 'artifact_ref', artifact: { artifactId: 'b1', name: '纪要.md' } }), + ] + expect(dedupeDeliverables(parts)).toEqual([ + { artifact: { artifactId: 'a2', name: '精美版.docx' }, updates: 1 }, + { artifact: { artifactId: 'b1', name: '纪要.md' }, updates: 0 }, + ]) + }) + + it('folds a same-run error into the assistant message that has a body', () => { + const body = { id: 'm1', runId: 'r1', role: 'assistant', parts: [p({ type: 'text', text: '做到这里' })] } + const err = { id: 'm2', runId: 'r1', role: 'assistant', parts: [p({ type: 'error', error: { code: 'budget', message: 'max turns' } })] } + const folded = foldRunErrors([body, err]) + expect(folded.map(m => m.id)).toEqual(['m1']) + expect(folded[0].parts.map(part => part.type)).toEqual(['text', 'error']) + expect(humanError('run.cancelled', '已取消').title).toBe('已取消') + expect(humanError('budget', 'max turns').title).toContain('已停止') + expect(processSummary(12, 3, '2026-10-01T00:00:00.000Z', '2026-10-01T00:03:13.000Z')) + .toBe('已工作 3 分 13 秒 · 12 次工具调用 · 3 个文件') + }) +}) + describe('workedLabel', () => { it('formats the run span and degrades without timestamps', () => { expect(workedLabel('2026-10-01T00:00:00.000Z', '2026-10-01T00:01:05.000Z')).toBe('已工作 1 分 5 秒') diff --git a/test/toolkind.test.ts b/test/toolkind.test.ts index c0d5911..52ce3bd 100644 --- a/test/toolkind.test.ts +++ b/test/toolkind.test.ts @@ -39,7 +39,7 @@ describe('timelineEntries', () => { p({ type: 'tool_call', process: true, toolCall: { id: 'c', name: 'run_python', arguments: '{"script":"print(1)"}' } }), ] const rows = timelineEntries(parts, true) - expect(rows.map(r => r.kind)).toEqual(['thinking', 'tools', 'text', 'tools']) + expect(rows.map(r => r.kind)).toEqual(['thinking', 'tools', 'text', 'toolblock']) const reads = rows[1] if (reads.kind !== 'tools') throw new Error('expected tool group') expect(reads.toolKind).toBe('read') @@ -49,8 +49,9 @@ describe('timelineEntries', () => { if (thinking.kind !== 'thinking') throw new Error('expected thinking') expect(thinking.active).toBe(false) const exec = rows[3] - if (exec.kind !== 'tools') throw new Error('expected exec group') - expect(exec.items[0].running).toBe(true) + if (exec.kind !== 'toolblock') throw new Error('expected exec block') + expect(exec.item.running).toBe(true) + expect(exec.item.headline).toBe('print(1)') }) it('marks the trailing thinking row active while the run is live', () => {