Show tool progress and keep the final reply visible #35

Merged
weichen merged 1 commits from feature/stream-display-overhaul into main 2026-10-02 05:05:02 +00:00
12 changed files with 525 additions and 97 deletions
+1 -1
View File
@@ -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']
+88 -27
View File
@@ -1,17 +1,17 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// 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 }) {
</div>
)
}
case 'error':
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
case 'error': {
const view = humanError(part.error?.code, part.error?.message)
return (
<div className="part-error">
<div>{view.title}</div>
{view.detail && (
<Collapsible id={`err:${part.error?.code ?? 'e'}:${view.detail.length}`} triggerClassName="process-collapse-summary"
trigger={<><span className="process-collapse-chev">▸</span>详情</>}>
<div className="part-error-detail">{view.detail}</div>
</Collapsible>
)}
<button type="button" className="btn ghost" onClick={() => { void store.sendMessage('继续', []) }}>继续执行</button>
</div>
)
}
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
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
<span className="dc-meta">{kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{updates > 0 ? ` · 已更新 ${updates} 次` : ''}{art ? '' : ' · 生成中'}</span>
</span>
</button>
<button type="button" className="dc-act" onClick={open} disabled={!art}>预览</button>
@@ -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 (
<span className="live-bar">
<span className="live-dot" />
<span className="mk-sweep">{live.phase}</span>
<span className="mk-sweep">{livePhaseLabel(live)}</span>
<span className="live-elapsed">已工作 {elapsed}</span>
</span>
)
}
// 用户在运行中手动折过的 run:结束后沿用这次选择,不强制再收起。
const touchedProcess = new Set<string>()
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 = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
const toggleProcess = (next: boolean) => {
if (msg.runId) touchedProcess.add(msg.runId)
setPersistedOpen(memKey, next)
setOpen(next)
}
return (
<div className="msg assistant" data-msg-id={msg.id}>
<div className="msg-role">
@@ -196,23 +242,24 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
<span>助手</span>
{live && <LiveBar live={live} />}
</div>
{split.timeline.length > 0 && (showProcess
? <div className="part-group">{timeline}</div>
: (
<div className="process-collapse">
<Collapsible id={`process:${msg.id}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>{workedLabel(run?.createdAt, run?.endedAt)}</span>
</>}>
<div className="body">{timeline}</div>
</Collapsible>
</div>
))}
{split.final.map((part, i) => (
<div className="part" key={i}>
<PartView part={part} live={!!(live && i === lastText && part.type === 'text')} />
{split.timeline.length > 0 && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
</>}>
<div className="body">{timeline}</div>
</Collapsible>
</div>
)}
{split.final.map((part, i) => part.type === 'artifact_ref' ? null : (
<div className="part" key={i}>
<PartView part={part} live={!!(streamingText && i === lastText && part.type === 'text')} />
</div>
))}
{cards.map(card => (
<DeliverableCard key={card.artifact.artifactId} artifact={card.artifact} updates={card.updates} />
))}
{extras.map(art => (
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
@@ -379,6 +426,8 @@ export function Conversation() {
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
const [dragDepth, setDragDepth] = useState(0)
const bodyRef = useRef<HTMLDivElement>(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 && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
</div>}
</div>
{showJump && (
<button type="button" className="jump-bottom" onClick={() => {
stickRef.current = true
setShowJump(false)
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}}>回到底部</button>
)}
<div className="input-bar">
<OutboxPanel />
+36 -3
View File
@@ -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 <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
if (row.kind === 'toolblock') {
return <ToolCallBlock key={`${idKey}:tb:${i}`} item={row.item} />
}
if (row.kind === 'tools') {
const running = row.items.some(it => it.running)
return (
@@ -55,3 +58,33 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
</div>
)
}
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 (
<div className="tl-toolblock">
<ToolBlock
toolId={item.id}
name={item.name}
detail={detail}
running={item.running}
status={item.status}
statusText={composing ? `编写中 · ${sizeLabel(item.arguments?.length ?? 0)}` : undefined}
statusDetail={item.summary}
autoCollapseOnComplete
>
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
{item.output && <pre className="tool-preview">{item.output}</pre>}
</ToolBlock>
{tail && <pre className="tool-live-preview">{tail}</pre>}
</div>
)
}
+5 -1
View File
@@ -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<P = unknown> {
// 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 }
+156 -33
View File
@@ -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<LiveItem, { kind: 'tool' }>
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<number, boolean>()
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
}
+81 -3
View File
@@ -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<Part['artifact']>; updates: number }[] {
const order: string[] = []
const byName = new Map<string, { artifact: NonNullable<Part['artifact']>; 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<T extends { id: string; runId?: string; role: string; parts: Part[] }>(messages: T[]): T[] {
const extras = new Map<string, Part[]>()
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<string>()
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
+18 -6
View File
@@ -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)
+12
View File
@@ -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; }
+49 -12
View File
@@ -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<ToolKind> = 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
+34 -7
View File
@@ -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)
+41 -1
View File
@@ -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<Part> & { 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 秒')
+4 -3
View File
@@ -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', () => {