fix(chat): group run output and hide empty messages
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
date: 2026-11-05
|
||||||
|
pr: pending
|
||||||
|
feature: Empty assistant bubbles during tool activity
|
||||||
|
impact: Whitespace-only assistant messages are hidden, and assistant text within a turn is displayed together with a completion marker. Stream data and tool activity are unchanged.
|
||||||
|
---
|
||||||
|
|
||||||
|
Tool cycles can leave assistant messages containing only whitespace. The chat
|
||||||
|
list excludes these empty bubbles while retaining the underlying streamed data,
|
||||||
|
so later text can still be appended to the same message.
|
||||||
|
Assistant messages within a turn are combined for display; tool rows remain
|
||||||
|
available below the combined text, and a separator marks the end of the turn.
|
||||||
@@ -19,6 +19,7 @@ import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue"
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import VirtualMessageList from "./VirtualMessageList.vue";
|
import VirtualMessageList from "./VirtualMessageList.vue";
|
||||||
import MessageItem from "./MessageItem.vue";
|
import MessageItem from "./MessageItem.vue";
|
||||||
|
import { groupRunMessages } from "./groupRunMessages";
|
||||||
import { LIVE_CHAT_MAX_LOADED_MESSAGES, useChatStore, type Message } from "@/stores/hermes/chat";
|
import { LIVE_CHAT_MAX_LOADED_MESSAGES, useChatStore, type Message } from "@/stores/hermes/chat";
|
||||||
import thinkingImage from "@/assets/thinking.gif";
|
import thinkingImage from "@/assets/thinking.gif";
|
||||||
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||||
@@ -100,10 +101,15 @@ const emptyState = computed(() => ({
|
|||||||
|
|
||||||
const displayMessages = computed(() => {
|
const displayMessages = computed(() => {
|
||||||
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
|
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
|
||||||
return chatStore.messages.filter((m) => {
|
const messages = chatStore.messages.filter((m) => {
|
||||||
if (m.role === "tool") {
|
if (m.role === "tool") {
|
||||||
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
|
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
|
||||||
}
|
}
|
||||||
|
// Tool cycles can produce whitespace-only assistant deltas. Keep them in
|
||||||
|
// the store for subsequent deltas, but don't render an empty bubble.
|
||||||
|
if (m.role === "assistant" && !m.content?.trim() && !m.reasoning?.trim() && !m.attachments?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
m.role === "assistant" &&
|
m.role === "assistant" &&
|
||||||
m.isStreaming &&
|
m.isStreaming &&
|
||||||
@@ -115,6 +121,7 @@ const displayMessages = computed(() => {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
return groupRunMessages(messages, chatStore.isRunActive || !!chatStore.abortState);
|
||||||
});
|
});
|
||||||
|
|
||||||
function forkDividerId(sessionId: string): string {
|
function forkDividerId(sessionId: string): string {
|
||||||
@@ -484,7 +491,8 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #item="{ message: msg }">
|
<template #item="{ message: msg }">
|
||||||
<div v-if="msg.systemType === 'fork-divider' && forkLineage" class="fork-divider" role="separator">
|
<div v-if="msg.systemType === 'run-end'" class="run-end" role="separator">{{ t('chat.runOutputEnded') }}</div>
|
||||||
|
<div v-else-if="msg.systemType === 'fork-divider' && forkLineage" class="fork-divider" role="separator">
|
||||||
<div class="fork-divider-line" aria-hidden="true"></div>
|
<div class="fork-divider-line" aria-hidden="true"></div>
|
||||||
<div class="fork-divider-pill">
|
<div class="fork-divider-pill">
|
||||||
<span class="fork-divider-icon" aria-hidden="true">
|
<span class="fork-divider-icon" aria-hidden="true">
|
||||||
@@ -867,6 +875,13 @@ defineExpose({
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/styles/variables" as *;
|
@use "@/styles/variables" as *;
|
||||||
|
|
||||||
|
.run-end {
|
||||||
|
margin: 20px auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-list-shell {
|
.message-list-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Message } from '@/stores/hermes/chat'
|
||||||
|
|
||||||
|
// Group only within an uninterrupted turn. Tool rows remain available after
|
||||||
|
// the combined answer; user, command and system messages are hard boundaries.
|
||||||
|
export function groupRunMessages(messages: Message[], runActive: boolean): Message[] {
|
||||||
|
const result: Message[] = []
|
||||||
|
let assistants: Message[] = []
|
||||||
|
let tools: Message[] = []
|
||||||
|
let marker: string | null = null
|
||||||
|
|
||||||
|
function flush(completed: boolean) {
|
||||||
|
if (assistants.length) {
|
||||||
|
const first = assistants[0]
|
||||||
|
result.push({
|
||||||
|
...first,
|
||||||
|
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,
|
||||||
|
attachments: assistants.flatMap(m => m.attachments || []),
|
||||||
|
isStreaming: assistants.some(m => m.isStreaming),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result.push(...tools)
|
||||||
|
if (completed && assistants.length) {
|
||||||
|
result.push({ id: `run-end-${assistants[0].id}`, role: 'system', content: '', timestamp: assistants[assistants.length - 1].timestamp, systemType: 'run-end' })
|
||||||
|
}
|
||||||
|
assistants = []
|
||||||
|
tools = []
|
||||||
|
marker = null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role !== 'assistant' && message.role !== 'tool') {
|
||||||
|
flush(true)
|
||||||
|
result.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (marker && message.runMarker && marker !== message.runMarker) flush(true)
|
||||||
|
marker ||= message.runMarker || null
|
||||||
|
if (message.role === 'assistant') assistants.push(message)
|
||||||
|
else tools.push(message)
|
||||||
|
}
|
||||||
|
flush(!runActive)
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -459,6 +459,7 @@ export default {
|
|||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
chat: {
|
chat: {
|
||||||
|
runOutputEnded: 'Run output ended',
|
||||||
messageLoadFailed: 'Could not load this conversation. Please retry.',
|
messageLoadFailed: 'Could not load this conversation. Please retry.',
|
||||||
contextRemaining: 'remaining',
|
contextRemaining: 'remaining',
|
||||||
contextClickToEdit: 'Click to edit context length',
|
contextClickToEdit: 'Click to edit context length',
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ export default {
|
|||||||
|
|
||||||
// 对话
|
// 对话
|
||||||
chat: {
|
chat: {
|
||||||
|
runOutputEnded: '本轮输出结束',
|
||||||
messageLoadFailed: '会话加载失败,请重试。',
|
messageLoadFailed: '会话加载失败,请重试。',
|
||||||
contextRemaining: '剩余',
|
contextRemaining: '剩余',
|
||||||
contextClickToEdit: '点击编辑上下文长度',
|
contextClickToEdit: '点击编辑上下文长度',
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface Message {
|
|||||||
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
||||||
reasoning?: string
|
reasoning?: string
|
||||||
queued?: boolean
|
queued?: boolean
|
||||||
systemType?: 'command' | 'error' | 'fork-divider'
|
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end'
|
||||||
commandAction?: string
|
commandAction?: string
|
||||||
commandData?: Record<string, unknown>
|
commandData?: Record<string, unknown>
|
||||||
finishReason?: string | null
|
finishReason?: string | null
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { groupRunMessages } from '../../packages/client/src/components/hermes/chat/groupRunMessages'
|
||||||
|
import type { Message } from '../../packages/client/src/stores/hermes/chat'
|
||||||
|
|
||||||
|
const message = (id: string, role: Message['role'], content = ''): Message => ({ id, role, content, timestamp: 1 })
|
||||||
|
|
||||||
|
describe('groupRunMessages', () => {
|
||||||
|
it('groups text across tool calls and marks completion only when the run ends', () => {
|
||||||
|
const input = [message('u', 'user', 'question'), message('a', 'assistant', 'first'), message('t', 'tool'), message('b', 'assistant', 'second')]
|
||||||
|
const active = groupRunMessages(input, true)
|
||||||
|
expect(active.map(m => m.role)).toEqual(['user', 'assistant', 'tool'])
|
||||||
|
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
|
||||||
|
const finished = groupRunMessages(input, false)
|
||||||
|
expect(finished.at(-1)?.systemType).toBe('run-end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps separate user turns and explicit run markers separate', () => {
|
||||||
|
const input = [message('u', 'user'), { ...message('a', 'assistant', 'one'), runMarker: '1' }, { ...message('b', 'assistant', 'two'), runMarker: '2' }, message('u2', 'user'), message('c', 'assistant', 'three')]
|
||||||
|
expect(groupRunMessages(input, false).filter(m => m.role === 'assistant').map(m => m.content)).toEqual(['one', 'two', 'three'])
|
||||||
|
expect(groupRunMessages(input, false).filter(m => m.systemType === 'run-end')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -82,6 +82,62 @@ test('streams a run and renders markdown, code, math and mermaid without leaking
|
|||||||
expect(api.unexpectedRequests).toEqual([])
|
expect(api.unexpectedRequests).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('does not render empty assistant bubbles between tool calls', async ({ page }) => {
|
||||||
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
|
await mockHermesApi(page)
|
||||||
|
await mockChatSocket(page)
|
||||||
|
await page.goto('/#/hermes/chat')
|
||||||
|
await sendChatMessage(page, 'run tools')
|
||||||
|
const { sessionId } = await waitForRunSessionId(page)
|
||||||
|
|
||||||
|
await page.evaluate((sid) => {
|
||||||
|
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||||
|
const common = { session_id: sid, run_id: 'blank-bubbles' }
|
||||||
|
socket.__trigger('run.started', { ...common, event: 'run.started' })
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: '\n ' })
|
||||||
|
socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: `tool-${i}`, tool: 'terminal' })
|
||||||
|
socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: `tool-${i}`, output: 'done' })
|
||||||
|
}
|
||||||
|
}, sessionId)
|
||||||
|
await expect(page.locator('.message.assistant')).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.evaluate((sid) => {
|
||||||
|
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||||
|
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'blank-bubbles', delta: '\n' })
|
||||||
|
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'blank-bubbles', delta: 'Visible answer' })
|
||||||
|
}, sessionId)
|
||||||
|
await expect(page.locator('.message.assistant')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.message.assistant')).toContainText('Visible answer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('groups assistant output across tool calls until the run ends', async ({ page }) => {
|
||||||
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
|
await mockHermesApi(page)
|
||||||
|
await mockChatSocket(page)
|
||||||
|
await page.goto('/#/hermes/chat')
|
||||||
|
await sendChatMessage(page, 'inspect')
|
||||||
|
const { sessionId } = await waitForRunSessionId(page)
|
||||||
|
await page.evaluate((sid) => {
|
||||||
|
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||||
|
const common = { session_id: sid, run_id: 'grouped-run' }
|
||||||
|
socket.__trigger('run.started', { ...common, event: 'run.started' })
|
||||||
|
socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'First update' })
|
||||||
|
socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: 't1', tool: 'terminal' })
|
||||||
|
socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: 't1', output: 'done' })
|
||||||
|
socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'Second update' })
|
||||||
|
}, sessionId)
|
||||||
|
const assistant = page.locator('.message.assistant')
|
||||||
|
await expect(assistant).toHaveCount(1)
|
||||||
|
await expect(assistant).toContainText('First update')
|
||||||
|
await expect(assistant).toContainText('Second update')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
test('opens the session model dialog and the session search from the chat header', async ({ page }) => {
|
test('opens the session model dialog and the session search from the chat header', async ({ page }) => {
|
||||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
await mockHermesApi(page, { sessions: [sampleSession] })
|
await mockHermesApi(page, { sessions: [sampleSession] })
|
||||||
|
|||||||
Reference in New Issue
Block a user