fix(chat): separate merged output segments
Build / build (push) Waiting to run
Playwright / e2e (push) Waiting to run

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 }),
);
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> 标签。
// 若两者共存,则拼接展示(罕见,但保持信息不丢)。
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0));
@@ -1111,11 +1119,12 @@ onBeforeUnmount(() => {
<MarkdownRenderer :content="thinkingFullText" />
</div>
</div>
<MarkdownRenderer
v-if="parsedThinking.body && message.role === 'assistant'"
:content="parsedThinking.body"
:heading-id-prefix="effectiveHeadingIdPrefix"
/>
<template v-if="message.role === 'assistant'">
<template v-for="(segment, index) in assistantBodySegments" :key="index">
<hr v-if="index > 0" class="assistant-segment-divider" />
<MarkdownRenderer :content="segment" :heading-id-prefix="index ? `${effectiveHeadingIdPrefix}-${index}` : effectiveHeadingIdPrefix" />
</template>
</template>
<!-- Render user message content -->
<template v-if="message.role === 'user'">
@@ -1160,7 +1169,7 @@ onBeforeUnmount(() => {
<!-- Render assistant message content -->
<MarkdownRenderer
v-if="message.role === 'assistant' && message.content && !parsedThinking.body"
v-if="message.role === 'assistant' && message.content && !assistantBodySegments.length"
:content="message.content"
: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 {
display: flex;
align-items: center;
@@ -910,7 +910,7 @@ defineExpose({
}
.run-end {
margin: 20px auto;
margin: -12px auto 0;
text-align: center;
color: var(--color-text-3);
font-size: 12px;
@@ -14,6 +14,7 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
result.push({
...first,
toolMessages: tools.length ? tools : undefined,
contentSegments: assistants.map(m => m.content).filter(text => text.trim()),
id: assistants[assistants.length - 1].id,
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,
@@ -65,6 +65,7 @@ export interface Message {
queued?: boolean
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end' | 'tool-group'
toolMessages?: Message[]
contentSegments?: string[]
commandAction?: string
commandData?: Record<string, unknown>
finishReason?: string | null
@@ -11,6 +11,7 @@ describe('groupRunMessages', () => {
expect(active.map(m => m.role)).toEqual(['user', 'assistant'])
expect(active[1].toolMessages).toEqual([input[2]])
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
expect(active[1].contentSegments).toEqual(['first', 'second'])
const finished = groupRunMessages(input, false)
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)
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('code')).toHaveCount(2)
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).toContainText('First 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 page.evaluate((sid) => {
(window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'grouped-run' })
}, sessionId)
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 }) => {