diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index a347a43..42511a2 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -7,7 +7,8 @@ import type { Message, Part, HITLCard } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui' import { docBridge } from './Workbench' -marked.setOptions({ breaks: true }) +// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染, +// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。 // Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。 function PartView({ part, live }: { part: Part; live?: boolean | null }) { @@ -16,7 +17,13 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) { return
case 'thinking': - return
{part.text}
+ // 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。 + return ( +
+ 思考过程 +
{part.text}
+
+ ) case 'tool_call': return 🛠 {part.toolCall?.name} case 'tool_result': { @@ -93,7 +100,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null {live && {live.phase}}
{split.process.length > 0 && (showProcessInline - ? split.process.map((p, i) =>
) + ? split.process.map((p, i) =>
) : )} {split.content.map((p, i) =>
)} {live && } @@ -102,6 +109,22 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null ) } +// 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 ( @@ -212,7 +235,7 @@ export function Conversation() { useEffect(() => { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }) - }, [messages.length, live?.text, live?.thinking, activeHitl.length]) + }, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length]) const send = async () => { if (!text.trim() && attachmentIds.length === 0) return @@ -279,6 +302,8 @@ export function Conversation() { } return })} + {live && !messages.some(m => m.runId === live.runId) && + } {activeHitl.map(c => )} {messages.length === 0 &&
发送消息开始对话
diff --git a/src/store.ts b/src/store.ts index 24422e7..48cda25 100644 --- a/src/store.ts +++ b/src/store.ts @@ -308,10 +308,12 @@ export const store = { return } if (out.run) { - set({ - live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() }, - messages: [...state.messages, out.message], - }) + // 竞态保护: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({ messages: [...state.messages, out.message] }) store.connectIfNeeded() } } catch (e) { diff --git a/src/styles.css b/src/styles.css index 6bea78a..00571b9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -50,16 +50,35 @@ button { font-family: inherit; } .msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; } .msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; } .part { margin: 4px 0; } -.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; } +.part-thinking { color: var(--text-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; } +.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; } +.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; } .part-tool { font-size: 12.5px; } .tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); } .tool-chip .status-ok { color: var(--ok); } .tool-chip .status-error { color: var(--err); } -.part-text { white-space: pre-wrap; line-height: 1.7; } -.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; } +.part-text { line-height: 1.65; overflow-wrap: break-word; } +/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行, + 叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */ +.part-text > *:first-child { margin-top: 0; } +.part-text > *:last-child { margin-bottom: 0; } +.part-text p { margin: 0.5em 0; } +.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; } +.part-text h1 { font-size: 1.3em; } +.part-text h2 { font-size: 1.18em; } +.part-text h3 { font-size: 1.06em; } +.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; } +.part-text li { margin: 0.15em 0; } +.part-text li > p { margin: 0.2em 0; } .part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; } .part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; } -.part-text :is(p,ul,ol) { margin: 0.4em 0; } +.part-text pre code { background: none; padding: 0; } +.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border); color: var(--text-dim); } +.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; } +.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; } +.part-text th { background: var(--bg-raised); font-weight: 600; } +.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; } +.part-text a { color: var(--accent); } .part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; } .artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; } .artifact-chip:hover { filter: brightness(1.15); }