fix(chat): separate merged output segments
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-11-05 15:52:53 +08:00
parent 43eda89d47
commit 10ce9e5c28
7 changed files with 42 additions and 7 deletions
@@ -0,0 +1,9 @@
---
date: 2026-11-05
pr: pending
feature: Separate merged agent output
impact: Individual assistant messages within one run remain in one bubble but have visual dividers; the run completion marker is closer to the message. Stored text and chat events are unchanged.
---
Message boundaries are retained as display-only segments. The combined text
used for copying and speech remains unchanged.
@@ -228,6 +228,14 @@ const parsedThinking = computed(() =>
parseThinking(props.message.content || "", { streaming: !!props.message.isStreaming }), parseThinking(props.message.content || "", { streaming: !!props.message.isStreaming }),
); );
const segmentBoundary = '\uE000hermes-output-boundary\uE001';
const assistantBodySegments = computed(() => {
const segments = props.message.contentSegments;
if (!segments || segments.length < 2) return parsedThinking.value.body ? [parsedThinking.value.body] : [];
const parsed = parseThinking(segments.join(segmentBoundary), { streaming: !!props.message.isStreaming });
return parsed.body.split(segmentBoundary).filter(segment => segment.trim());
});
// 优先使用来自 reasoning 字段/事件的思考文本;否则回退到从 content 解析的 <think> 标签。 // 优先使用来自 reasoning 字段/事件的思考文本;否则回退到从 content 解析的 <think> 标签。
// 若两者共存,则拼接展示(罕见,但保持信息不丢)。 // 若两者共存,则拼接展示(罕见,但保持信息不丢)。
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0)); const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0));
@@ -1111,11 +1119,12 @@ onBeforeUnmount(() => {
<MarkdownRenderer :content="thinkingFullText" /> <MarkdownRenderer :content="thinkingFullText" />
</div> </div>
</div> </div>
<MarkdownRenderer <template v-if="message.role === 'assistant'">
v-if="parsedThinking.body && message.role === 'assistant'" <template v-for="(segment, index) in assistantBodySegments" :key="index">
:content="parsedThinking.body" <hr v-if="index > 0" class="assistant-segment-divider" />
:heading-id-prefix="effectiveHeadingIdPrefix" <MarkdownRenderer :content="segment" :heading-id-prefix="index ? `${effectiveHeadingIdPrefix}-${index}` : effectiveHeadingIdPrefix" />
/> </template>
</template>
<!-- Render user message content --> <!-- Render user message content -->
<template v-if="message.role === 'user'"> <template v-if="message.role === 'user'">
@@ -1160,7 +1169,7 @@ onBeforeUnmount(() => {
<!-- Render assistant message content --> <!-- Render assistant message content -->
<MarkdownRenderer <MarkdownRenderer
v-if="message.role === 'assistant' && message.content && !parsedThinking.body" v-if="message.role === 'assistant' && message.content && !assistantBodySegments.length"
:content="message.content" :content="message.content"
:heading-id-prefix="effectiveHeadingIdPrefix" :heading-id-prefix="effectiveHeadingIdPrefix"
/> />
@@ -1647,6 +1656,12 @@ onBeforeUnmount(() => {
} }
} }
.assistant-segment-divider {
border: 0;
border-top: 1px solid var(--color-border-2);
margin: 12px 0;
}
.message-meta { .message-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -910,7 +910,7 @@ defineExpose({
} }
.run-end { .run-end {
margin: 20px auto; margin: -12px auto 0;
text-align: center; text-align: center;
color: var(--color-text-3); color: var(--color-text-3);
font-size: 12px; font-size: 12px;
@@ -14,6 +14,7 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
result.push({ result.push({
...first, ...first,
toolMessages: tools.length ? tools : undefined, toolMessages: tools.length ? tools : undefined,
contentSegments: assistants.map(m => m.content).filter(text => text.trim()),
id: assistants[assistants.length - 1].id, id: assistants[assistants.length - 1].id,
content: assistants.map(m => m.content).filter(text => text.trim()).join('\n\n'), content: assistants.map(m => m.content).filter(text => text.trim()).join('\n\n'),
reasoning: assistants.map(m => m.reasoning).filter(Boolean).join('\n\n') || undefined, reasoning: assistants.map(m => m.reasoning).filter(Boolean).join('\n\n') || undefined,
@@ -65,6 +65,7 @@ export interface Message {
queued?: boolean queued?: boolean
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end' | 'tool-group' systemType?: 'command' | 'error' | 'fork-divider' | 'run-end' | 'tool-group'
toolMessages?: Message[] toolMessages?: Message[]
contentSegments?: string[]
commandAction?: string commandAction?: string
commandData?: Record<string, unknown> commandData?: Record<string, unknown>
finishReason?: string | null finishReason?: string | null
@@ -11,6 +11,7 @@ describe('groupRunMessages', () => {
expect(active.map(m => m.role)).toEqual(['user', 'assistant']) expect(active.map(m => m.role)).toEqual(['user', 'assistant'])
expect(active[1].toolMessages).toEqual([input[2]]) expect(active[1].toolMessages).toEqual([input[2]])
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' }) expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
expect(active[1].contentSegments).toEqual(['first', 'second'])
const finished = groupRunMessages(input, false) const finished = groupRunMessages(input, false)
expect(finished.at(-1)?.systemType).toBe('run-end') expect(finished.at(-1)?.systemType).toBe('run-end')
}) })
+8
View File
@@ -57,6 +57,7 @@ test('streams a run and renders markdown, code, math and mermaid without leaking
}, sessionId) }, sessionId)
const bubble = page.locator('.message').last() const bubble = page.locator('.message').last()
await expect(bubble.locator('.assistant-segment-divider')).toHaveCount(0)
await expect(bubble.locator('strong')).toHaveText('bold') await expect(bubble.locator('strong')).toHaveText('bold')
await expect(bubble.locator('code')).toHaveCount(2) await expect(bubble.locator('code')).toHaveCount(2)
await expect(bubble.locator('.katex')).toHaveCount(1) await expect(bubble.locator('.katex')).toHaveCount(1)
@@ -132,11 +133,18 @@ test('groups assistant output across tool calls until the run ends', async ({ pa
await expect(assistant).toHaveCount(1) await expect(assistant).toHaveCount(1)
await expect(assistant).toContainText('First update') await expect(assistant).toContainText('First update')
await expect(assistant).toContainText('Second update') await expect(assistant).toContainText('Second update')
await expect(assistant.locator('.assistant-segment-divider')).toHaveCount(1)
await expect(assistant.locator('.message-bubble')).toHaveCount(1)
await expect(page.locator('.run-end')).toHaveCount(0) await expect(page.locator('.run-end')).toHaveCount(0)
await page.evaluate((sid) => { await page.evaluate((sid) => {
(window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'grouped-run' }) (window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'grouped-run' })
}, sessionId) }, sessionId)
await expect(page.locator('.run-end')).toHaveText('Run output ended') await expect(page.locator('.run-end')).toHaveText('Run output ended')
await expect.poll(async () => {
const bubble = await assistant.locator('.message-bubble').boundingBox()
const end = await page.locator('.run-end').boundingBox()
return bubble && end ? end.y - (bubble.y + bubble.height) : Infinity
}).toBeLessThan(50)
}) })
test('shows agent activity under its own avatar before the answer', async ({ page }) => { test('shows agent activity under its own avatar before the answer', async ({ page }) => {