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 }),
|
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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user