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