Collapse process output the moment the final reply starts streaming (Cursor-style)

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)。
This commit is contained in:
2026-09-30 14:54:37 +00:00
parent b2fbcf72bc
commit 642299ddd4
5 changed files with 124 additions and 24 deletions
+1
View File
@@ -0,0 +1 @@
/home/weichen/projects/Agent-System-Dev/modules/frontend/node_modules
+32 -20
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked' import { marked } from 'marked'
import { api } from '../api' import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store' import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { liveToMessage } from '../live'
import type { Message, Part, HITLCard } from '../contracts' import type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui' import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
import { docBridge } from './Workbench' 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 (
<details className="process-collapse long-reply">
<summary>
<span>📄 长回复</span>
<span>{text.length} 字(点击展开)</span>
</summary>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</details>
)
}
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) { function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
const autoCollapse = useStore(s => s.ui.autoCollapseProcess) const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined)) 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 return out
}, [parts]) }, [parts])
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。 // 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式)
const showProcessInline = live || !autoCollapse // 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。
// run 终态后:按偏好折叠为摘要行(可回看)。
const showProcessInline = live ? live.phase !== 'responding' : !autoCollapse
const lastContentIdx = split.content.length - 1
return ( return (
<div className="msg assistant"> <div className="msg assistant">
<div className="msg-role"> <div className="msg-role">
@@ -104,29 +125,20 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
{split.process.length > 0 && (showProcessInline {split.process.length > 0 && (showProcessInline
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>) ? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
: <ProcessCollapse parts={split.process} />)} : <ProcessCollapse parts={split.process} />)}
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)} {split.content.map((p, i) => {
// CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。
if (!live && p.type === 'text' && (p.text?.length ?? 0) > LONG_REPLY_CHARS)
return <LongReplyCollapse key={i} text={p.text ?? ''} />
return <div className="part" key={i}>
<PartView part={p} live={!!(live && i === lastContentIdx && p.type === 'text')} />
</div>
})}
{live && <LiveAgentNotes live={live} />} {live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />} {!live && ctxObs && <ContextObsView obs={ctxObs} />}
</div> </div>
) )
} }
// 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):模型实际看到什么、裁了什么——可解释性入口。 // 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) { function ContextObsView({ obs }: { obs: ContextObs }) {
return ( return (
@@ -238,7 +250,7 @@ export function Conversation() {
useEffect(() => { useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }) 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 () => { const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return if (!text.trim() && attachmentIds.length === 0) return
+36
View File
@@ -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: '' }
}
+7 -4
View File
@@ -2,6 +2,7 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api' import { api, connectEvents } from './api'
import { applyTextDelta } from './live'
import type { import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList, Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard, AgentProfile, StreamEvent, PluginPanel, ActivityCard,
@@ -17,7 +18,9 @@ export interface LiveState {
runId: string runId: string
phase: string phase: string
thinking: string thinking: string
text: string // 按 delta index 分桶的文本:新桶只在新轮开启(必经工具轮),故除最后一桶外
// 均为工具轮过程解说(process);最后一桶是正在流式的回复。
texts: { index: number; text: string }[]
toolCalls: { name: string; status?: string; summary?: string }[] toolCalls: { name: string; status?: string; summary?: string }[]
agentNotes: Record<string, string> agentNotes: Record<string, string>
startedAt: number startedAt: number
@@ -253,7 +256,7 @@ export const store = {
switch (e.type) { switch (e.type) {
case 'message.delta': { case 'message.delta': {
const p = e.payload as { index: number; text: string } 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 break
} }
case 'thinking.delta': { case 'thinking.delta': {
@@ -367,7 +370,7 @@ export const store = {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run), // 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。 // 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) { 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] }) set({ messages: [...state.messages, out.message] })
store.connectIfNeeded() store.connectIfNeeded()
@@ -501,7 +504,7 @@ export const store = {
} }
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null { 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 } } const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } }
mutate(next) mutate(next)
return next return next
+48
View File
@@ -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> = {}): 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)
})
})