mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
fix(agent): preserve streamed message order
This commit is contained in:
@@ -20,6 +20,21 @@ interface AgentToolCall {
|
|||||||
status: 'running' | 'done'
|
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 {
|
interface AgentMessageAttachment {
|
||||||
kind: AgentAttachmentKind
|
kind: AgentAttachmentKind
|
||||||
url: string
|
url: string
|
||||||
@@ -66,6 +81,7 @@ interface AgentChatMessage {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
status: AgentMessageStatus
|
status: AgentMessageStatus
|
||||||
tools: AgentToolCall[]
|
tools: AgentToolCall[]
|
||||||
|
segments: AgentMessageSegment[]
|
||||||
attachments: AgentMessageAttachment[]
|
attachments: AgentMessageAttachment[]
|
||||||
choices: AgentChoiceCard[]
|
choices: AgentChoiceCard[]
|
||||||
choice_selection?: AgentChoiceSelection
|
choice_selection?: AgentChoiceSelection
|
||||||
@@ -517,26 +533,57 @@ function normalizeChoiceSelectionMessages(sessionMessages: AgentChatMessage[]) {
|
|||||||
return sessionMessages
|
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) {
|
function normalizeStoredMessages(value: unknown) {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
|
const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
|
||||||
const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
|
const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
|
||||||
const role = message.role === 'assistant' ? 'assistant' : 'user'
|
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 {
|
return {
|
||||||
...message,
|
...message,
|
||||||
id: stringifyChoiceField(message.id) || createId(role),
|
id: stringifyChoiceField(message.id) || createId(role),
|
||||||
role,
|
role,
|
||||||
content: typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content),
|
content,
|
||||||
createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
|
createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
|
||||||
status: normalizeMessageStatus(message.status),
|
status: normalizeMessageStatus(message.status),
|
||||||
attachments: Array.isArray(message.attachments) ? message.attachments : [],
|
attachments: Array.isArray(message.attachments) ? message.attachments : [],
|
||||||
choices: Array.isArray(message.choices)
|
choices: Array.isArray(message.choices)
|
||||||
? (message.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
|
? (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),
|
choice_selection: normalizeChoiceSelection(message.choice_selection || message.choiceSelection),
|
||||||
} as AgentChatMessage
|
} as AgentChatMessage
|
||||||
})
|
})
|
||||||
@@ -813,7 +860,7 @@ function failStreamRecovery() {
|
|||||||
.find(message => message.role === 'assistant' && message.status === 'streaming')
|
.find(message => message.role === 'assistant' && message.status === 'streaming')
|
||||||
if (assistantMessage) {
|
if (assistantMessage) {
|
||||||
assistantMessage.status = 'error'
|
assistantMessage.status = 'error'
|
||||||
assistantMessage.content ||= t('agentAssistant.recoveryFailed')
|
if (!assistantMessage.content) appendAssistantTextSegment(assistantMessage, t('agentAssistant.recoveryFailed'))
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
} else {
|
} else {
|
||||||
@@ -1212,6 +1259,7 @@ function addMessage(
|
|||||||
attachments,
|
attachments,
|
||||||
choices: [],
|
choices: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
|
segments: role === 'assistant' && content ? [{ type: 'text', content }] : [],
|
||||||
choice_selection: choiceSelection,
|
choice_selection: choiceSelection,
|
||||||
}
|
}
|
||||||
messages.value.push(message)
|
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) {
|
function isEmptyAssistantMessage(message: AgentChatMessage) {
|
||||||
return (
|
return (
|
||||||
@@ -1257,6 +1339,7 @@ function applyMessageUpdate(event: AgentStreamEvent) {
|
|||||||
message.content = typeof target?.content === 'string' ? target.content : ''
|
message.content = typeof target?.content === 'string' ? target.content : ''
|
||||||
message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
|
message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
|
||||||
message.tools = Array.isArray(target?.tools) ? target.tools : []
|
message.tools = Array.isArray(target?.tools) ? target.tools : []
|
||||||
|
message.segments = normalizeMessageSegments(target?.segments, message.content, message.tools)
|
||||||
message.choices = Array.isArray(target?.choices)
|
message.choices = Array.isArray(target?.choices)
|
||||||
? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
|
? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
|
||||||
: []
|
: []
|
||||||
@@ -1272,7 +1355,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'delta':
|
case 'delta':
|
||||||
assistantMessage.content += event.content || ''
|
appendAssistantTextSegment(assistantMessage, event.content || '')
|
||||||
emit('assistant-preview', assistantMessage.content)
|
emit('assistant-preview', assistantMessage.content)
|
||||||
break
|
break
|
||||||
case 'tool':
|
case 'tool':
|
||||||
@@ -1282,6 +1365,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
|||||||
message: normalizeToolMessage(event.message || ''),
|
message: normalizeToolMessage(event.message || ''),
|
||||||
status: 'running',
|
status: 'running',
|
||||||
})
|
})
|
||||||
|
assistantMessage.segments.push({ type: 'tool', toolIndex: assistantMessage.tools.length - 1 })
|
||||||
break
|
break
|
||||||
case 'attachment':
|
case 'attachment':
|
||||||
if (event.attachment?.url) {
|
if (event.attachment?.url) {
|
||||||
@@ -1308,7 +1392,9 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
|||||||
case 'error':
|
case 'error':
|
||||||
assistantMessage.status = 'error'
|
assistantMessage.status = 'error'
|
||||||
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
|
// 后端流式错误已经以 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)
|
emit('assistant-preview', assistantMessage.content)
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
break
|
break
|
||||||
@@ -1643,7 +1729,7 @@ async function streamAgentMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.status = 'error'
|
assistantMessage.status = 'error'
|
||||||
assistantMessage.content = error?.message || t('agentAssistant.error')
|
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2247,22 +2333,29 @@ onScopeDispose(() => {
|
|||||||
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
|
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.tools.length" class="agent-assistant-tools">
|
<div v-if="message.role === 'assistant' && message.segments.length" class="agent-assistant-segments">
|
||||||
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
|
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
|
||||||
<VIcon
|
<div
|
||||||
:icon="
|
v-if="segment.type === 'text'"
|
||||||
tool.status === 'running' && message.status === 'streaming'
|
class="agent-assistant-message__bubble markdown-body"
|
||||||
? 'line-md:loading-twotone-loop'
|
v-html="renderMarkdown(segment.content)"
|
||||||
: 'mdi-check-circle-outline'
|
|
||||||
"
|
|
||||||
size="16"
|
|
||||||
/>
|
/>
|
||||||
<span>{{ tool.message }}</span>
|
<div v-else class="agent-assistant-tool">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="message.content"
|
v-else-if="message.content"
|
||||||
class="agent-assistant-message__bubble markdown-body"
|
class="agent-assistant-message__bubble markdown-body"
|
||||||
v-html="renderMarkdown(message.content)"
|
v-html="renderMarkdown(message.content)"
|
||||||
/>
|
/>
|
||||||
@@ -2365,6 +2458,7 @@ onScopeDispose(() => {
|
|||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
!message.content &&
|
!message.content &&
|
||||||
|
!message.segments.length &&
|
||||||
!message.attachments.length &&
|
!message.attachments.length &&
|
||||||
!message.choices.length &&
|
!message.choices.length &&
|
||||||
message.status === 'streaming'
|
message.status === 'streaming'
|
||||||
@@ -2934,11 +3028,14 @@ onScopeDispose(() => {
|
|||||||
background: var(--agent-assistant-assistant-bg);
|
background: var(--agent-assistant-assistant-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-assistant-tools {
|
.agent-assistant-segments {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
inline-size: min(100%, 34rem);
|
inline-size: min(100%, 34rem);
|
||||||
margin-block-end: 0.5rem;
|
}
|
||||||
|
|
||||||
|
.agent-assistant-segments .agent-assistant-message__bubble {
|
||||||
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-assistant-tool {
|
.agent-assistant-tool {
|
||||||
|
|||||||
@@ -237,4 +237,65 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
|
|
||||||
wrapper.unmount()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user