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)
+ })
+})