fix(chat): separate merged output segments
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user