From 642299ddd496c82d162175a120e6568c721a073b Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 14:54:37 +0000 Subject: [PATCH 1/4] Collapse process output the moment the final reply starts streaming (Cursor-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 流式期间思考/工具/中间轮解说与最终回复混排在中栏,用户难以扫读答复;参考 Cursor:回复开始输出时过程即时折叠,可手动展开回看。 Evidence: npx tsc --noEmit 通过;npx vitest run 20/20(新增 test/live.test.ts:分桶、除末桶外 process 标记、终态同构)。 Impact: LiveState.text 改为按 delta index 分桶的 texts;responding 阶段过程折叠、acting 重展开;终态新增 >800 字长回复折叠(CF0);无契约变更。 Refs: 任务卡 feature/chat-process-split-r1;配套 backend 提交 0e76f59(工具轮文本 process=true)。 --- node_modules | 1 + src/components/Conversation.tsx | 52 ++++++++++++++++++++------------- src/live.ts | 36 +++++++++++++++++++++++ src/store.ts | 11 ++++--- test/live.test.ts | 48 ++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 24 deletions(-) create mode 120000 node_modules create mode 100644 src/live.ts create mode 100644 test/live.test.ts diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..0179db8 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/weichen/projects/Agent-System-Dev/modules/frontend/node_modules \ No newline at end of file diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 41c8103..cd7a34f 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -4,6 +4,7 @@ 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 { liveToMessage } from '../live' import type { Message, Part, HITLCard } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui' import { docBridge } from './Workbench' @@ -82,6 +83,23 @@ function ProcessCollapse({ parts }: { parts: Part[] }) { ) } +// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。 +const LONG_REPLY_CHARS = 800 + +function LongReplyCollapse({ text }: { text: string }) { + return ( +
+ + 📄 长回复 + {text.length} 字(点击展开) + +
+
+
+
+ ) +} + 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)) @@ -92,8 +110,11 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null return out }, [parts]) - // 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。 - const showProcessInline = live || !autoCollapse + // 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式) + // 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。 + // run 终态后:按偏好折叠为摘要行(可回看)。 + const showProcessInline = live ? live.phase !== 'responding' : !autoCollapse + const lastContentIdx = split.content.length - 1 return (
@@ -104,29 +125,20 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null {split.process.length > 0 && (showProcessInline ? split.process.map((p, i) =>
) : )} - {split.content.map((p, i) =>
)} + {split.content.map((p, i) => { + // CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。 + if (!live && p.type === 'text' && (p.text?.length ?? 0) > LONG_REPLY_CHARS) + return + return
+ +
+ })} {live && } {!live && ctxObs && }
) } -// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息 -// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。 -function liveToMessage(live: LiveState): Message { - const parts: Part[] = [] - if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true }) - for (const tc of live.toolCalls) { - parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } }) - if (tc.status !== undefined) { - parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name }, - toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } }) - } - } - if (live.text) parts.push({ type: 'text', text: live.text }) - return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } -} - // 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。 function ContextObsView({ obs }: { obs: ContextObs }) { return ( @@ -238,7 +250,7 @@ export function Conversation() { useEffect(() => { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }) - }, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length]) + }, [messages.length, live?.texts, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length]) const send = async () => { if (!text.trim() && attachmentIds.length === 0) return diff --git a/src/live.ts b/src/live.ts new file mode 100644 index 0000000..dac750b --- /dev/null +++ b/src/live.ts @@ -0,0 +1,36 @@ +// live 流式投影纯逻辑(中栏过程/回复分离):按 delta index 分桶、合成临时消息。 +// 新文本桶只在新轮开启(必经工具轮),故除最后一桶外均为工具轮过程解说(process); +// 最后一桶是正在流式的回复——与落库消息的 process 语义同构,终态对账无跳变。 +import type { Message, Part } from './contracts' +import type { LiveState } from './store' + +export interface TextBucket { index: number; text: string } + +// applyTextDelta 把一段正文增量并入桶序列:index 与末桶相同则追加,否则开新桶。 +export function applyTextDelta(buckets: TextBucket[], index: number, delta: string): TextBucket[] { + const last = buckets[buckets.length - 1] + if (last && last.index === index) { + const out = buckets.slice() + out[out.length - 1] = { index, text: last.text + delta } + return out + } + return [...buckets, { index, text: delta }] +} + +// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息, +// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。 +export function liveToMessage(live: LiveState): Message { + const parts: Part[] = [] + if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true }) + for (let i = 0; i < live.texts.length; i++) { + parts.push({ type: 'text', text: live.texts[i].text, process: i < live.texts.length - 1 || undefined }) + } + for (const tc of live.toolCalls) { + parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } }) + if (tc.status !== undefined) { + parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name }, + toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } }) + } + } + return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } +} diff --git a/src/store.ts b/src/store.ts index 0006b8f..bf064ff 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,6 +2,7 @@ // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 import { useSyncExternalStore } from 'react' import { api, connectEvents } from './api' +import { applyTextDelta } from './live' import type { Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList, AgentProfile, StreamEvent, PluginPanel, ActivityCard, @@ -17,7 +18,9 @@ export interface LiveState { runId: string phase: string thinking: string - text: string + // 按 delta index 分桶的文本:新桶只在新轮开启(必经工具轮),故除最后一桶外 + // 均为工具轮过程解说(process);最后一桶是正在流式的回复。 + texts: { index: number; text: string }[] toolCalls: { name: string; status?: string; summary?: string }[] agentNotes: Record startedAt: number @@ -253,7 +256,7 @@ export const store = { switch (e.type) { case 'message.delta': { const p = e.payload as { index: number; text: string } - set({ live: touchLive(e.runId ?? '', l => { l.text += p.text }) }) + set({ live: touchLive(e.runId ?? '', l => { l.texts = applyTextDelta(l.texts, p.index, p.text) }) }) break } case 'thinking.delta': { @@ -367,7 +370,7 @@ export const store = { // 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run), // 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。 if (!state.live || state.live.runId !== out.run.id) { - set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } }) + set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: Date.now() } }) } set({ messages: [...state.messages, out.message] }) store.connectIfNeeded() @@ -501,7 +504,7 @@ export const store = { } function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null { - const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } + const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: Date.now() } const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } } mutate(next) return next diff --git a/test/live.test.ts b/test/live.test.ts new file mode 100644 index 0000000..afac657 --- /dev/null +++ b/test/live.test.ts @@ -0,0 +1,48 @@ +// live 流式投影纯逻辑测试(中栏过程/回复分离):分桶、process 标记、终态同构。 +import { describe, it, expect } from 'vitest' +import { applyTextDelta, liveToMessage, type TextBucket } from '../src/live' +import type { LiveState } from '../src/store' + +function live(over: Partial = {}): LiveState { + return { runId: 'r1', phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: 0, ...over } +} + +describe('applyTextDelta', () => { + it('appends to the last bucket when index matches', () => { + let b: TextBucket[] = [] + b = applyTextDelta(b, 0, '先查') + b = applyTextDelta(b, 0, '一下') + expect(b).toEqual([{ index: 0, text: '先查一下' }]) + }) + it('opens a new bucket when index advances (new turn)', () => { + let b: TextBucket[] = applyTextDelta([], 0, '解说') + b = applyTextDelta(b, 1, '最终') + b = applyTextDelta(b, 1, '回复') + expect(b).toEqual([{ index: 0, text: '解说' }, { index: 1, text: '最终回复' }]) + }) +}) + +describe('liveToMessage', () => { + it('marks all but the last text bucket as process (tool-turn commentary)', () => { + const msg = liveToMessage(live({ + thinking: '思考', + texts: [{ index: 0, text: '我先更新待办。' }, { index: 1, text: '完成了。' }], + toolCalls: [{ name: 'update_todos', status: 'ok' }], + })) + const texts = msg.parts.filter(p => p.type === 'text') + expect(texts.map(p => p.process)).toEqual([true, undefined]) + expect(texts.map(p => p.text)).toEqual(['我先更新待办。', '完成了。']) + }) + it('single bucket (final turn only) carries no process flag', () => { + const msg = liveToMessage(live({ texts: [{ index: 0, text: '直接回答' }] })) + expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([undefined]) + }) + it('thinking and tool parts are always process', () => { + const msg = liveToMessage(live({ + thinking: '想', + texts: [{ index: 0, text: '答' }], + toolCalls: [{ name: 'web_search', status: 'ok', summary: '命中' }], + })) + for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true) + }) +}) From 22a87232d7296d1e8513f818bb5644cce13b43f0 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 15:28:29 +0000 Subject: [PATCH 2/4] Untrack the node_modules symlink and ignore both directory and link forms Why: 642299d committed node_modules as a symlink pointing at the absolute path /home/weichen/projects/Agent-System-Dev/modules/frontend/node_modules. The .gitignore rule "node_modules/" only matches directories, not symlinks, so the link slipped through and other clones would receive this machine's path while normal installs get bypassed. Evidence: git ls-tree HEAD node_modules => mode 120000 blob before this commit; git check-ignore -v node_modules exits 0 with rule ".gitignore:2:node_modules" after unstaging the link. Impact: fresh clones no longer carry a machine-specific symlink; local installs work normally. No code changes. Refs: code review of feature/chat-process-split-r1 (2026-09-30) --- .gitignore | 3 ++- node_modules | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) delete mode 120000 node_modules diff --git a/.gitignore b/.gitignore index e5537be..69830df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -node_modules/ +# 无斜杠形式同时忽略目录与符号链接(目录式规则不匹配 symlink,曾致链接被提交)。 +node_modules dist/ *.local diff --git a/node_modules b/node_modules deleted file mode 120000 index 0179db8..0000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/weichen/projects/Agent-System-Dev/modules/frontend/node_modules \ No newline at end of file From 754ce83ffdb14482ddcaa94921bb07d359dea5f8 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 15:30:41 +0000 Subject: [PATCH 3/4] Interleave live parts per turn and mark commentary process on tool.call Why: liveToMessage emitted all text buckets then all tools, while the persisted message interleaves per turn (thinking, text, calls), so parts reordered on reconcile with 2+ tool turns. Commentary stayed in the reply area until the next turn's text bucket appeared, because the process flag was positional (i < length - 1) instead of tool-driven. Evidence: LiveState now holds an arrival-ordered items stream (thinking/text deltas merge into their last segment; tools land in place), so event order - which matches the backend's per-turn append order - maps 1:1 onto persisted parts. A text segment is process iff a tool.call lands before the next thinking/text segment, aligning with the backend's Process: len(calls) > 0; the collapse predicate moved to testable showProcessInline. Long replies (>800 chars) now show a 100-char excerpt and label the count as Markdown source length. vitest 27/27 pass (was 5 live cases, now 12 incl. multi-turn order, immediate process marking, collapse predicate); tsc -b clean. Impact: streaming UI no longer reshuffles parts across turns or on final reconcile; tool-turn commentary folds into the process area as soon as the tool call arrives. Residual edge (documented in live.ts): a silent tool-only turn with no text and no thinking attributes its call to the previous text segment. Refs: code review of feature/chat-process-split-r1 (2026-09-30) --- src/components/Conversation.tsx | 13 +-- src/live.ts | 95 +++++++++++++++----- src/store.ts | 28 +++--- test/live.test.ts | 148 +++++++++++++++++++++++++------- tsconfig.tsbuildinfo | 2 +- 5 files changed, 212 insertions(+), 74 deletions(-) diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index cd7a34f..a1af7ad 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -4,7 +4,7 @@ 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 { liveToMessage } from '../live' +import { liveToMessage, showProcessInline } from '../live' import type { Message, Part, HITLCard } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui' import { docBridge } from './Workbench' @@ -87,11 +87,14 @@ function ProcessCollapse({ parts }: { parts: Part[] }) { const LONG_REPLY_CHARS = 800 function LongReplyCollapse({ text }: { text: string }) { + // 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。 + const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100) return (
📄 长回复 - {text.length} 字(点击展开) + {text.length} 字(按 Markdown 源码计) + {excerpt && {excerpt}…}
@@ -113,7 +116,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null // 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式) // 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。 // run 终态后:按偏好折叠为摘要行(可回看)。 - const showProcessInline = live ? live.phase !== 'responding' : !autoCollapse + const showProcess = showProcessInline(live ?? null, autoCollapse) const lastContentIdx = split.content.length - 1 return (
@@ -122,7 +125,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null 助手 {live && {live.phase}}
- {split.process.length > 0 && (showProcessInline + {split.process.length > 0 && (showProcess ? split.process.map((p, i) =>
) : )} {split.content.map((p, i) => { @@ -250,7 +253,7 @@ export function Conversation() { useEffect(() => { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }) - }, [messages.length, live?.texts, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length]) + }, [messages.length, live?.items, live?.phase, activeHitl.length]) const send = async () => { if (!text.trim() && attachmentIds.length === 0) return diff --git a/src/live.ts b/src/live.ts index dac750b..2992b1a 100644 --- a/src/live.ts +++ b/src/live.ts @@ -1,36 +1,91 @@ -// live 流式投影纯逻辑(中栏过程/回复分离):按 delta index 分桶、合成临时消息。 -// 新文本桶只在新轮开启(必经工具轮),故除最后一桶外均为工具轮过程解说(process); -// 最后一桶是正在流式的回复——与落库消息的 process 语义同构,终态对账无跳变。 +// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text +// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库 +// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。 +// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定, +// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区, +// 无需等下一轮文本段开启。 +// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议 +// 携带轮次归属才能消除;现行事件流无法区分)。 import type { Message, Part } from './contracts' import type { LiveState } from './store' -export interface TextBucket { index: number; text: string } +// 到达序部件流的元素。 +export type LiveItem = + | { kind: 'thinking'; text: string } + | { kind: 'text'; index: number; text: string } + | { kind: 'tool'; name: string; status?: string; summary?: string } -// applyTextDelta 把一段正文增量并入桶序列:index 与末桶相同则追加,否则开新桶。 -export function applyTextDelta(buckets: TextBucket[], index: number, delta: string): TextBucket[] { - const last = buckets[buckets.length - 1] - if (last && last.index === index) { - const out = buckets.slice() - out[out.length - 1] = { index, text: last.text + delta } +// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。 +export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] { + const last = items[items.length - 1] + if (last && last.kind === 'thinking') { + const out = items.slice() + out[out.length - 1] = { kind: 'thinking', text: last.text + delta } return out } - return [...buckets, { index, text: delta }] + return [...items, { kind: 'thinking', text: delta }] +} + +// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。 +export function applyTextDelta(items: LiveItem[], index: number, delta: string): LiveItem[] { + const last = items[items.length - 1] + if (last && last.kind === 'text' && last.index === index) { + const out = items.slice() + out[out.length - 1] = { kind: 'text', index, text: last.text + delta } + return out + } + return [...items, { kind: 'text', index, text: delta }] +} + +export function applyToolCall(items: LiveItem[], name: string): LiveItem[] { + return [...items, { kind: 'tool', name }] +} + +// applyToolResult 回填最近一个未决工具(与落库的尾部配对语义一致)。 +export function applyToolResult(items: LiveItem[], status: string, summary?: string): LiveItem[] { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i] + if (it.kind === 'tool' && it.status === undefined) { + const out = items.slice() + out[i] = { kind: 'tool', name: it.name, status, summary } + return out + } + } + return items } // liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息, // 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。 export function liveToMessage(live: LiveState): Message { - const parts: Part[] = [] - if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true }) - for (let i = 0; i < live.texts.length; i++) { - parts.push({ type: 'text', text: live.texts[i].text, process: i < live.texts.length - 1 || undefined }) + // 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。 + const textProcess = new Map() + for (let i = 0; i < live.items.length; i++) { + if (live.items[i].kind !== 'text') continue + let hasCalls = false + for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true + textProcess.set(i, hasCalls) } - for (const tc of live.toolCalls) { - parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } }) - if (tc.status !== undefined) { - parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name }, - toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } }) + const parts: Part[] = [] + for (let i = 0; i < live.items.length; i++) { + const it = live.items[i] + if (it.kind === 'thinking') { + if (it.text) parts.push({ type: 'thinking', text: it.text, process: true }) + } else if (it.kind === 'tool') { + parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: it.name } }) + if (it.status !== undefined) { + parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: it.name }, + toolResult: { callId: '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } }) + } + } else { + parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined }) } } return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } } + +// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入 +// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。 +// 非 live 消息随「自动折叠过程」偏好。 +export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean { + return live ? live.phase !== 'responding' : !autoCollapse +} diff --git a/src/store.ts b/src/store.ts index bf064ff..04047ca 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 } from './live' +import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, type LiveItem } from './live' import type { Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList, AgentProfile, StreamEvent, PluginPanel, ActivityCard, @@ -17,11 +17,9 @@ export interface ContextObs { export interface LiveState { runId: string phase: string - thinking: string - // 按 delta index 分桶的文本:新桶只在新轮开启(必经工具轮),故除最后一桶外 - // 均为工具轮过程解说(process);最后一桶是正在流式的回复。 - texts: { index: number; text: string }[] - toolCalls: { name: string; status?: string; summary?: string }[] + // 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序 + // 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。 + items: LiveItem[] agentNotes: Record startedAt: number } @@ -256,26 +254,22 @@ export const store = { switch (e.type) { case 'message.delta': { const p = e.payload as { index: number; text: string } - set({ live: touchLive(e.runId ?? '', l => { l.texts = applyTextDelta(l.texts, p.index, p.text) }) }) + set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) }) break } case 'thinking.delta': { const p = e.payload as { index: number; text: string } - set({ live: touchLive(e.runId ?? '', l => { l.thinking += p.text }) }) + set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) }) break } case 'tool.call': { const p = e.payload as { callId: string; name: string } - set({ live: touchLive(e.runId ?? '', l => { l.toolCalls.push({ name: p.name }) }) }) + set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name) }) }) break } case 'tool.result': { const p = e.payload as { callId: string; status: string; summary?: string } - set({ live: touchLive(e.runId ?? '', l => { - for (let i = l.toolCalls.length - 1; i >= 0; i--) { - if (l.toolCalls[i].status === undefined) { l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }; break } - } - }) }) + set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.status, p.summary) }) }) break } case 'run.phase': { @@ -370,7 +364,7 @@ export const store = { // 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run), // 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。 if (!state.live || state.live.runId !== out.run.id) { - set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: Date.now() } }) + set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } }) } set({ messages: [...state.messages, out.message] }) store.connectIfNeeded() @@ -504,8 +498,8 @@ export const store = { } function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null { - const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: Date.now() } - const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } } + const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } + const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } } mutate(next) return next } diff --git a/test/live.test.ts b/test/live.test.ts index afac657..62c9726 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -1,48 +1,134 @@ -// live 流式投影纯逻辑测试(中栏过程/回复分离):分桶、process 标记、终态同构。 +// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、 +// 轮次穿插同构、过程区折叠谓词。 import { describe, it, expect } from 'vitest' -import { applyTextDelta, liveToMessage, type TextBucket } from '../src/live' +import { + applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, + liveToMessage, showProcessInline, type LiveItem, +} from '../src/live' import type { LiveState } from '../src/store' function live(over: Partial = {}): LiveState { - return { runId: 'r1', phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: 0, ...over } + return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over } } -describe('applyTextDelta', () => { - it('appends to the last bucket when index matches', () => { - let b: TextBucket[] = [] - b = applyTextDelta(b, 0, '先查') - b = applyTextDelta(b, 0, '一下') - expect(b).toEqual([{ index: 0, text: '先查一下' }]) +// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。 +function twoTurnItems(): LiveItem[] { + let items: LiveItem[] = [] + items = applyThinkingDelta(items, '先想想') + items = applyThinkingDelta(items, '一下') + items = applyTextDelta(items, 0, '我先更新待办。') + items = applyToolCall(items, 'update_todos') + items = applyToolResult(items, 'ok', '已更新') + items = applyThinkingDelta(items, '再想想') + items = applyTextDelta(items, 1, '最终') + items = applyTextDelta(items, 1, '回复') + return items +} + +describe('applyThinkingDelta / applyTextDelta', () => { + it('merges thinking deltas into the last thinking segment', () => { + let items: LiveItem[] = [] + items = applyThinkingDelta(items, '先') + items = applyThinkingDelta(items, '想想') + expect(items).toEqual([{ kind: 'thinking', text: '先想想' }]) }) - it('opens a new bucket when index advances (new turn)', () => { - let b: TextBucket[] = applyTextDelta([], 0, '解说') - b = applyTextDelta(b, 1, '最终') - b = applyTextDelta(b, 1, '回复') - expect(b).toEqual([{ index: 0, text: '解说' }, { index: 1, text: '最终回复' }]) + it('opens a new thinking segment after text/tool (turn boundary)', () => { + let items: LiveItem[] = applyThinkingDelta([], '第一轮思考') + items = applyTextDelta(items, 0, '解说') + items = applyThinkingDelta(items, '第二轮思考') + expect(items).toEqual([ + { kind: 'thinking', text: '第一轮思考' }, + { kind: 'text', index: 0, text: '解说' }, + { kind: 'thinking', text: '第二轮思考' }, + ]) + }) + it('appends text to the last segment when index matches', () => { + let items: LiveItem[] = [] + items = applyTextDelta(items, 0, '先查') + items = applyTextDelta(items, 0, '一下') + expect(items).toEqual([{ kind: 'text', index: 0, text: '先查一下' }]) + }) + it('opens a new text segment when index advances (new turn)', () => { + let items: LiveItem[] = applyTextDelta([], 0, '解说') + items = applyTextDelta(items, 1, '最终') + items = applyTextDelta(items, 1, '回复') + expect(items).toEqual([ + { kind: 'text', index: 0, text: '解说' }, + { kind: 'text', index: 1, text: '最终回复' }, + ]) + }) + it('fills the most recent pending tool call', () => { + let items: LiveItem[] = [] + items = applyToolCall(items, 'a') + items = applyToolCall(items, 'b') + items = applyToolResult(items, 'ok', '结果') + expect(items).toEqual([ + { kind: 'tool', name: 'a' }, + { kind: 'tool', name: 'b', status: 'ok', summary: '结果' }, + ]) }) }) describe('liveToMessage', () => { - it('marks all but the last text bucket as process (tool-turn commentary)', () => { - const msg = liveToMessage(live({ - thinking: '思考', - texts: [{ index: 0, text: '我先更新待办。' }, { index: 1, text: '完成了。' }], - toolCalls: [{ name: 'update_todos', status: 'ok' }], - })) - const texts = msg.parts.filter(p => p.type === 'text') - expect(texts.map(p => p.process)).toEqual([true, undefined]) - expect(texts.map(p => p.text)).toEqual(['我先更新待办。', '完成了。']) + it('interleaves parts per turn in arrival order, matching persisted parts', () => { + const msg = liveToMessage(live({ items: twoTurnItems() })) + expect(msg.parts).toEqual([ + { type: 'thinking', text: '先想想一下', process: true }, + { type: 'text', text: '我先更新待办。', process: true }, + { type: 'tool_call', process: true, toolCall: { id: '', name: 'update_todos' } }, + { type: 'tool_result', process: true, toolCall: { id: '', name: 'update_todos' }, + toolResult: { callId: '', status: 'ok', summary: '已更新' } }, + { type: 'thinking', text: '再想想', process: true }, + { type: 'text', text: '最终回复' }, + ]) }) - it('single bucket (final turn only) carries no process flag', () => { - const msg = liveToMessage(live({ texts: [{ index: 0, text: '直接回答' }] })) - expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([undefined]) + it('marks the in-flight segment as process the moment its tool call arrives', () => { + let items: LiveItem[] = [] + items = applyTextDelta(items, 0, '我先查一下') + const before = liveToMessage(live({ items })) + expect(before.parts.map(p => p.process)).toEqual([undefined]) // 尚无工具:仍在回复位 + items = applyToolCall(items, 'web_search') + const after = liveToMessage(live({ items })) + expect(after.parts.map(p => p.process)).toEqual([true, true]) // tool.call 一到即改标 + }) + it('single final segment without tools carries no process flag', () => { + let items: LiveItem[] = [] + items = applyTextDelta(items, 0, '直接回答') + const msg = liveToMessage(live({ items })) + expect(msg.parts.map(p => p.process)).toEqual([undefined]) + }) + it('tools between segments keep every non-final segment in the process area', () => { + let items: LiveItem[] = [] + items = applyTextDelta(items, 0, '第一轮') + items = applyToolCall(items, 'a') + items = applyToolResult(items, 'ok') + items = applyTextDelta(items, 1, '第二轮') + items = applyToolCall(items, 'b') + items = applyTextDelta(items, 2, '最终回复') + const msg = liveToMessage(live({ items })) + expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, true, undefined]) + // 顺序必须与落库穿插序一致:text → tool → text → tool → text。 + expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call', 'text']) }) it('thinking and tool parts are always process', () => { - const msg = liveToMessage(live({ - thinking: '想', - texts: [{ index: 0, text: '答' }], - toolCalls: [{ name: 'web_search', status: 'ok', summary: '命中' }], - })) + let items: LiveItem[] = [] + items = applyThinkingDelta(items, '想') + items = applyTextDelta(items, 0, '答') + items = applyToolCall(items, 'web_search') + items = applyToolResult(items, 'ok', '命中') + const msg = liveToMessage(live({ items })) for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true) }) }) + +describe('showProcessInline', () => { + it('expands during thinking/acting and collapses the moment responding starts', () => { + expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true) + expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true) + expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false) + }) + it('falls back to the auto-collapse preference when no live run', () => { + expect(showProcessInline(null, true)).toBe(false) + expect(showProcessInline(null, false)).toBe(true) + }) +}) diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index e4fba48..5328699 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file From 64837575a91fa8f67e00de72bce12d58d3cc2676 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 15:47:22 +0000 Subject: [PATCH 4/4] Port artifact events to the items stream and finish the main merge Why: the merge commit left tsc broken in two spots - an artifact.created handler still pushing to the removed LiveState.parts, and main's newer ui icons not yet checked out in this worktree; also Part was imported unused. Evidence: artifact.created now appends an artifact LiveItem (process=true, matching the backend's ae8e5d0) projected to artifact_ref by liveToMessage; new test asserts it lands after its tool call. tsc -b exit 0 after initializing the ui submodule checkout; vitest 33/33. Impact: live artifact chips render inside the process stream and fold on reconcile; no functional change elsewhere. Refs: feature/chat-process-split-r1 --- src/live.ts | 8 ++++++++ src/store.ts | 6 +++--- test/live.test.ts | 14 +++++++++++++- tsconfig.tsbuildinfo | 2 +- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/live.ts b/src/live.ts index b07812b..b2f008d 100644 --- a/src/live.ts +++ b/src/live.ts @@ -14,6 +14,7 @@ export type LiveItem = | { kind: 'thinking'; text: string } | { kind: 'text'; index: number; text: string } | { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string } + | { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } } // applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。 export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] { @@ -41,6 +42,11 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string): 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[] { @@ -77,6 +83,8 @@ export function liveToMessage(live: LiveState): Message { 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 } }) } + } else if (it.kind === 'artifact') { + parts.push({ type: 'artifact_ref', process: true, artifact: it.artifact }) } else { parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined }) } diff --git a/src/store.ts b/src/store.ts index a4f1ee5..522118f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,10 +2,10 @@ // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 import { useSyncExternalStore } from 'react' import { api, connectEvents } from './api' -import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, type LiveItem } from './live' +import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live' import type { Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList, - AgentProfile, StreamEvent, PluginPanel, ActivityCard, Part, + AgentProfile, StreamEvent, PluginPanel, ActivityCard, } from './contracts' export interface ContextObs { @@ -342,7 +342,7 @@ export const store = { const p = e.payload as { artifact?: { id: string; name: string; kind?: string } } if (p?.artifact) { set({ live: touchLive(e.runId ?? '', l => { - l.parts.push({ type: 'artifact_ref', artifact: { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind } }) + l.items = applyArtifactCreated(l.items, { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind }) }) }) } if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId) diff --git a/test/live.test.ts b/test/live.test.ts index 0639695..c2549d6 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -2,7 +2,7 @@ // 轮次穿插同构、过程区折叠谓词。 import { describe, it, expect } from 'vitest' import { - applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, + applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, liveToMessage, showProcessInline, type LiveItem, } from '../src/live' import type { LiveState } from '../src/store' @@ -122,6 +122,18 @@ describe('liveToMessage', () => { const msg = liveToMessage(live({ items })) for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true) }) + it('artifact refs land after their tool call and are process', () => { + let items: LiveItem[] = [] + items = applyTextDelta(items, 0, '生成报表。') + items = applyToolCall(items, 'make_report', 'c1') + items = applyToolResult(items, 'c1', 'ok') + items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' }) + items = applyTextDelta(items, 1, '完成。') + const msg = liveToMessage(live({ items })) + expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'artifact_ref', 'text']) + expect(msg.parts.find(p => p.type === 'artifact_ref')).toMatchObject({ process: true }) + expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined]) + }) }) describe('showProcessInline', () => { diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index ea1364b..70fbd13 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file