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:
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/weichen/projects/Agent-System-Dev/modules/frontend/node_modules
|
||||
@@ -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 (
|
||||
<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 }) {
|
||||
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 (
|
||||
<div className="msg assistant">
|
||||
<div className="msg-role">
|
||||
@@ -104,29 +125,20 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
{split.process.length > 0 && (showProcessInline
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
|
||||
: <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 && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||
</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):模型实际看到什么、裁了什么——可解释性入口。
|
||||
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
|
||||
|
||||
+36
@@ -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
@@ -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<string, string>
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user