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 { 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
@@ -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 终态后由消息树对账收敛。
|
// 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
|
||||||
|
|||||||
@@ -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