fix(agent): preserve streamed message order

This commit is contained in:
jxxghp
2026-08-06 22:24:54 +08:00
parent 8badfb0da9
commit fc6313b79e
2 changed files with 180 additions and 22 deletions

View File

@@ -20,6 +20,21 @@ interface AgentToolCall {
status: 'running' | 'done'
}
interface AgentMessageTextSegment {
type: 'text'
content: string
}
interface AgentMessageToolSegment {
type: 'tool'
toolIndex: number
}
type AgentMessageSegment = AgentMessageTextSegment | AgentMessageToolSegment
type AgentRenderableMessageSegment =
(AgentMessageTextSegment & { key: string }) | { type: 'tool'; key: string; tool: AgentToolCall }
interface AgentMessageAttachment {
kind: AgentAttachmentKind
url: string
@@ -66,6 +81,7 @@ interface AgentChatMessage {
createdAt: number
status: AgentMessageStatus
tools: AgentToolCall[]
segments: AgentMessageSegment[]
attachments: AgentMessageAttachment[]
choices: AgentChoiceCard[]
choice_selection?: AgentChoiceSelection
@@ -517,26 +533,57 @@ function normalizeChoiceSelectionMessages(sessionMessages: AgentChatMessage[]) {
return sessionMessages
}
// 规范化历史消息,补齐附件、工具和选择项等可选数组
// 规范化消息的有序片段;旧历史按原来的工具在前、文本在后布局回退
function normalizeMessageSegments(value: unknown, content: string, tools: AgentToolCall[]) {
const normalizedSegments: AgentMessageSegment[] = []
if (Array.isArray(value)) {
value.forEach(rawSegment => {
if (!rawSegment || typeof rawSegment !== 'object' || Array.isArray(rawSegment)) return
const segment = rawSegment as Record<string, unknown>
if (segment.type === 'text' && typeof segment.content === 'string' && segment.content) {
normalizedSegments.push({ type: 'text', content: segment.content })
return
}
const toolIndex = Number(segment.toolIndex ?? segment.tool_index)
if (segment.type === 'tool' && Number.isInteger(toolIndex) && toolIndex >= 0 && toolIndex < tools.length) {
normalizedSegments.push({ type: 'tool', toolIndex })
}
})
}
if (normalizedSegments.length) return normalizedSegments
tools.forEach((_tool, toolIndex) => normalizedSegments.push({ type: 'tool', toolIndex }))
if (content) normalizedSegments.push({ type: 'text', content })
return normalizedSegments
}
// 规范化历史消息,补齐附件、工具、有序片段和选择项等可选数组。
function normalizeStoredMessages(value: unknown) {
if (!Array.isArray(value)) return []
const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
const role = message.role === 'assistant' ? 'assistant' : 'user'
const content = typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content)
const tools = Array.isArray(message.tools) ? (message.tools as AgentToolCall[]) : []
return {
...message,
id: stringifyChoiceField(message.id) || createId(role),
role,
content: typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content),
content,
createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
status: normalizeMessageStatus(message.status),
attachments: Array.isArray(message.attachments) ? message.attachments : [],
choices: Array.isArray(message.choices)
? (message.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
: [],
tools: Array.isArray(message.tools) ? message.tools : [],
tools,
segments: normalizeMessageSegments(message.segments, content, tools),
choice_selection: normalizeChoiceSelection(message.choice_selection || message.choiceSelection),
} as AgentChatMessage
})
@@ -813,7 +860,7 @@ function failStreamRecovery() {
.find(message => message.role === 'assistant' && message.status === 'streaming')
if (assistantMessage) {
assistantMessage.status = 'error'
assistantMessage.content ||= t('agentAssistant.recoveryFailed')
if (!assistantMessage.content) appendAssistantTextSegment(assistantMessage, t('agentAssistant.recoveryFailed'))
markToolsDone(assistantMessage)
refreshMessageList()
} else {
@@ -1212,6 +1259,7 @@ function addMessage(
attachments,
choices: [],
tools: [],
segments: role === 'assistant' && content ? [{ type: 'text', content }] : [],
choice_selection: choiceSelection,
}
messages.value.push(message)
@@ -1234,6 +1282,40 @@ function markToolsDone(message: AgentChatMessage) {
})
}
// 追加助手文本,并只合并紧邻的文本片段以保留工具事件边界。
function appendAssistantTextSegment(message: AgentChatMessage, content: string) {
if (!content) return
message.content += content
const lastSegment = message.segments.at(-1)
if (lastSegment?.type === 'text') {
lastSegment.content += content
} else {
message.segments.push({ type: 'text', content })
}
}
// 替换助手文本但保留工具片段,用于无法继续流式处理时显示错误。
function replaceAssistantTextSegments(message: AgentChatMessage, content: string) {
message.content = content
message.segments = message.segments.filter(segment => segment.type === 'tool')
if (content) message.segments.push({ type: 'text', content })
}
// 将消息片段转换为模板可直接渲染的文本或工具对象。
function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderableMessageSegment[] {
return message.segments.reduce<AgentRenderableMessageSegment[]>((renderableSegments, segment, index) => {
if (segment.type === 'text') {
renderableSegments.push({ ...segment, key: `text-${index}` })
return renderableSegments
}
const tool = message.tools[segment.toolIndex]
if (tool) renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool })
return renderableSegments
}, [])
}
// 判断消息是否没有任何可展示内容,可用于清理编辑回调产生的占位回复。
function isEmptyAssistantMessage(message: AgentChatMessage) {
return (
@@ -1257,6 +1339,7 @@ function applyMessageUpdate(event: AgentStreamEvent) {
message.content = typeof target?.content === 'string' ? target.content : ''
message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
message.tools = Array.isArray(target?.tools) ? target.tools : []
message.segments = normalizeMessageSegments(target?.segments, message.content, message.tools)
message.choices = Array.isArray(target?.choices)
? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
: []
@@ -1272,7 +1355,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
switch (event.type) {
case 'delta':
assistantMessage.content += event.content || ''
appendAssistantTextSegment(assistantMessage, event.content || '')
emit('assistant-preview', assistantMessage.content)
break
case 'tool':
@@ -1282,6 +1365,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
message: normalizeToolMessage(event.message || ''),
status: 'running',
})
assistantMessage.segments.push({ type: 'tool', toolIndex: assistantMessage.tools.length - 1 })
break
case 'attachment':
if (event.attachment?.url) {
@@ -1308,7 +1392,9 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
case 'error':
assistantMessage.status = 'error'
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
assistantMessage.content ||= event.message_i18n || event.message || t('agentAssistant.error')
if (!assistantMessage.content) {
appendAssistantTextSegment(assistantMessage, event.message_i18n || event.message || t('agentAssistant.error'))
}
emit('assistant-preview', assistantMessage.content)
markToolsDone(assistantMessage)
break
@@ -1643,7 +1729,7 @@ async function streamAgentMessage(
}
assistantMessage.status = 'error'
assistantMessage.content = error?.message || t('agentAssistant.error')
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
markToolsDone(assistantMessage)
refreshMessageList()
} finally {
@@ -2247,22 +2333,29 @@ onScopeDispose(() => {
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
</div>
<div v-if="message.tools.length" class="agent-assistant-tools">
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
<VIcon
:icon="
tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
<div v-if="message.role === 'assistant' && message.segments.length" class="agent-assistant-segments">
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
<div
v-if="segment.type === 'text'"
class="agent-assistant-message__bubble markdown-body"
v-html="renderMarkdown(segment.content)"
/>
<span>{{ tool.message }}</span>
</div>
<div v-else class="agent-assistant-tool">
<VIcon
:icon="
segment.tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
/>
<span>{{ segment.tool.message }}</span>
</div>
</template>
</div>
<div
v-if="message.content"
v-else-if="message.content"
class="agent-assistant-message__bubble markdown-body"
v-html="renderMarkdown(message.content)"
/>
@@ -2365,6 +2458,7 @@ onScopeDispose(() => {
<div
v-if="
!message.content &&
!message.segments.length &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
@@ -2934,11 +3028,14 @@ onScopeDispose(() => {
background: var(--agent-assistant-assistant-bg);
}
.agent-assistant-tools {
.agent-assistant-segments {
display: grid;
gap: 0.4rem;
gap: 0.5rem;
inline-size: min(100%, 34rem);
margin-block-end: 0.5rem;
}
.agent-assistant-segments .agent-assistant-message__bubble {
inline-size: 100%;
}
.agent-assistant-tool {

View File

@@ -237,4 +237,65 @@ describe('AgentAssistantPanel stream recovery', () => {
wrapper.unmount()
})
it('renders interleaved assistant text and tool events in their SSE order', async () => {
const serverSessionId = 'web-agent:ordered-segments'
const streamEvents = [
{ type: 'start', session_id: serverSessionId },
{ type: 'delta', content: '先检查服务器。' },
{ type: 'tool', message: '(执行了 1 条命令)' },
{ type: 'delta', content: '检查完成,没有发现错误。' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('检查服务器')
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toContain('先检查服务器。')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
expect(renderedSegments[1].text()).toContain('执行了 1 条命令')
expect(renderedSegments[2].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[2].text()).toContain('检查完成,没有发现错误。')
const saveCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/display'))
const savedMessages = JSON.parse(String(saveCall?.[1]?.body || '{}')).messages as Array<Record<string, unknown>>
expect(savedMessages.at(-1)).toMatchObject({
content: '先检查服务器。检查完成,没有发现错误。',
segments: [
{ type: 'text', content: '先检查服务器。' },
{ type: 'tool', toolIndex: 0 },
{ type: 'text', content: '检查完成,没有发现错误。' },
],
})
wrapper.unmount()
})
})