mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
perf(agent): smooth SSE streaming updates
This commit is contained in:
+3
-2
@@ -65,13 +65,14 @@ http {
|
||||
root html;
|
||||
}
|
||||
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
# 禁用缓存
|
||||
add_header Cache-Control no-cache;
|
||||
add_header Cache-Control "no-cache, no-transform";
|
||||
add_header X-Accel-Buffering no;
|
||||
gzip off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const ASSISTANT_PREVIEW_MAX_LENGTH = 480
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
@@ -1014,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
|
||||
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
|
||||
if (!props.active || !bubble.text) return
|
||||
|
||||
const existingIndex = fabBubbles.value.findIndex(item => item.id === bubble.id)
|
||||
if (existingIndex >= 0) {
|
||||
fabBubbles.value[existingIndex] = bubble
|
||||
setFabDocked(false)
|
||||
nextTick(scheduleFabBubblePositionUpdate)
|
||||
if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
|
||||
return
|
||||
}
|
||||
|
||||
const hadBubbles = hasFabBubbles.value
|
||||
const wasDocked = fabDocked.value
|
||||
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
|
||||
@@ -1061,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
|
||||
showBubble({
|
||||
id: 'assistant-preview',
|
||||
kind: 'assistant',
|
||||
text: value,
|
||||
text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
import AgentMarkdownContent from './AgentMarkdownContent.vue'
|
||||
|
||||
type AgentMessageRole = 'user' | 'assistant'
|
||||
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
||||
@@ -240,25 +239,16 @@ let recordingChunks: BlobPart[] = []
|
||||
let messageScrollFrame: number | null = null
|
||||
let pendingMessageScrollToBottom = false
|
||||
let streamPersistTimer: number | null = null
|
||||
let streamPersistLastRunAt = 0
|
||||
let messageScrollerShouldFollow = true
|
||||
let streamDeltaFrame: number | null = null
|
||||
let pendingStreamDelta = ''
|
||||
let pendingStreamDeltaMessage: AgentChatMessage | null = null
|
||||
let userAbortRequested = false
|
||||
let streamRecoveryAbortRequested = false
|
||||
let streamRecoveryTimer: number | null = null
|
||||
let activeStreamStartedAt = 0
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||||
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||
const canSend = computed(
|
||||
@@ -1105,12 +1095,6 @@ function persistState(options: { syncHistory?: boolean } = {}) {
|
||||
if (syncHistory) upsertCurrentSessionHistory()
|
||||
}
|
||||
|
||||
// 渲染助手消息中的 Markdown 文本。
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return md.render(value)
|
||||
}
|
||||
|
||||
// 拼接后端 API 地址。
|
||||
function resolveApiUrl(path: string) {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
|
||||
@@ -1155,6 +1139,11 @@ function isMessageScrollerNearBottom() {
|
||||
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
|
||||
}
|
||||
|
||||
// 只在滚动事件中更新自动跟随意图,避免每个流式事件触发布局读取。
|
||||
function handleMessageScrollerScroll() {
|
||||
messageScrollerShouldFollow = isMessageScrollerNearBottom()
|
||||
}
|
||||
|
||||
// 合并滚动更新请求,降低流式输出时的布局测量频率。
|
||||
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
||||
const { toBottom = false } = options
|
||||
@@ -1174,6 +1163,7 @@ function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
||||
// 将消息列表滚动到底部。
|
||||
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
||||
const { smooth = false } = options
|
||||
messageScrollerShouldFollow = true
|
||||
nextTick(() => {
|
||||
const scroller = getMessageScrollerElement()
|
||||
if (!scroller) return
|
||||
@@ -1217,13 +1207,17 @@ function clearMessageScrollFrame() {
|
||||
pendingMessageScrollToBottom = false
|
||||
}
|
||||
|
||||
// 延迟持久化流式消息,避免每个 token 都写入本地存储。
|
||||
// 流式期间至多每秒保存一次轻量当前态,终态再同步完整历史。
|
||||
function scheduleStreamPersist() {
|
||||
clearStreamPersistTimer()
|
||||
if (streamPersistTimer !== null) return
|
||||
|
||||
const elapsed = Date.now() - streamPersistLastRunAt
|
||||
const delay = Math.max(0, STREAM_STATE_PERSIST_DELAY - elapsed)
|
||||
streamPersistTimer = window.setTimeout(() => {
|
||||
persistState()
|
||||
streamPersistTimer = null
|
||||
}, STREAM_STATE_PERSIST_DELAY)
|
||||
streamPersistLastRunAt = Date.now()
|
||||
persistState({ syncHistory: false })
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// 同步输入框高度,使多行输入不撑破底部布局。
|
||||
@@ -1351,8 +1345,6 @@ function applyMessageUpdate(event: AgentStreamEvent) {
|
||||
|
||||
// 将单个 SSE 事件应用到正在流式输出的助手消息。
|
||||
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
||||
const shouldFollowBottom = isMessageScrollerNearBottom()
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta':
|
||||
appendAssistantTextSegment(assistantMessage, event.content || '')
|
||||
@@ -1410,10 +1402,54 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
|
||||
scheduleStreamPersist()
|
||||
nextTick(() => {
|
||||
scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
|
||||
scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow })
|
||||
})
|
||||
}
|
||||
|
||||
// 将同一条助手消息的连续文本增量合并到一个动画帧,语义事件到来前会同步冲刷。
|
||||
function flushPendingStreamDelta() {
|
||||
if (streamDeltaFrame !== null) {
|
||||
window.cancelAnimationFrame(streamDeltaFrame)
|
||||
streamDeltaFrame = null
|
||||
}
|
||||
if (!pendingStreamDeltaMessage || !pendingStreamDelta) return
|
||||
|
||||
const assistantMessage = pendingStreamDeltaMessage
|
||||
const content = pendingStreamDelta
|
||||
pendingStreamDeltaMessage = null
|
||||
pendingStreamDelta = ''
|
||||
applyStreamEvent({ type: 'delta', content }, assistantMessage)
|
||||
}
|
||||
|
||||
function clearPendingStreamDelta() {
|
||||
if (streamDeltaFrame !== null) window.cancelAnimationFrame(streamDeltaFrame)
|
||||
streamDeltaFrame = null
|
||||
pendingStreamDeltaMessage = null
|
||||
pendingStreamDelta = ''
|
||||
}
|
||||
|
||||
function schedulePendingStreamDeltaFlush() {
|
||||
if (streamDeltaFrame !== null) return
|
||||
|
||||
streamDeltaFrame = window.requestAnimationFrame(() => {
|
||||
streamDeltaFrame = null
|
||||
flushPendingStreamDelta()
|
||||
})
|
||||
}
|
||||
|
||||
function queueStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
||||
if (event.type !== 'delta') {
|
||||
flushPendingStreamDelta()
|
||||
applyStreamEvent(event, assistantMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingStreamDeltaMessage && pendingStreamDeltaMessage !== assistantMessage) flushPendingStreamDelta()
|
||||
pendingStreamDeltaMessage = assistantMessage
|
||||
pendingStreamDelta += event.content || ''
|
||||
schedulePendingStreamDeltaFlush()
|
||||
}
|
||||
|
||||
// 解析一个 SSE 数据块。
|
||||
function parseSseBlock(block: string) {
|
||||
const data = block
|
||||
@@ -1441,16 +1477,17 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
||||
const consumeEvent = (event: AgentStreamEvent | null) => {
|
||||
if (!event) return
|
||||
|
||||
applyStreamEvent(event, assistantMessage)
|
||||
queueStreamEvent(event, assistantMessage)
|
||||
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const blocks = buffer.split(/\n\n/)
|
||||
const blocks = buffer.split(/\r?\n\r?\n/)
|
||||
buffer = blocks.pop() || ''
|
||||
|
||||
for (const block of blocks) {
|
||||
@@ -1462,6 +1499,9 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
||||
if (buffer.trim()) {
|
||||
consumeEvent(parseSseBlock(buffer))
|
||||
}
|
||||
} finally {
|
||||
flushPendingStreamDelta()
|
||||
}
|
||||
|
||||
return { receivedTerminalEvent }
|
||||
}
|
||||
@@ -2172,6 +2212,7 @@ onScopeDispose(clearPendingAttachments)
|
||||
onScopeDispose(cancelVoiceRecording)
|
||||
onScopeDispose(clearMessageScrollFrame)
|
||||
onScopeDispose(clearStreamPersistTimer)
|
||||
onScopeDispose(clearPendingStreamDelta)
|
||||
onScopeDispose(clearStreamRecoveryTimer)
|
||||
onScopeDispose(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -2312,6 +2353,7 @@ onScopeDispose(() => {
|
||||
ref="messageListRef"
|
||||
class="agent-assistant-messages"
|
||||
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
|
||||
@scroll.passive="handleMessageScrollerScroll"
|
||||
>
|
||||
<div class="agent-assistant-messages__content">
|
||||
<div v-if="!hasMessages" class="agent-assistant-empty">
|
||||
@@ -2335,10 +2377,10 @@ onScopeDispose(() => {
|
||||
|
||||
<div v-if="message.role === 'assistant' && message.segments.length" class="agent-assistant-segments">
|
||||
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
|
||||
<div
|
||||
<AgentMarkdownContent
|
||||
v-if="segment.type === 'text'"
|
||||
class="agent-assistant-message__bubble markdown-body"
|
||||
v-html="renderMarkdown(segment.content)"
|
||||
:content="segment.content"
|
||||
:streaming="message.status === 'streaming'"
|
||||
/>
|
||||
<div v-else class="agent-assistant-tool">
|
||||
<VIcon
|
||||
@@ -2354,17 +2396,17 @@ onScopeDispose(() => {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<AgentMarkdownContent
|
||||
v-else-if="message.content"
|
||||
class="agent-assistant-message__bubble markdown-body"
|
||||
v-html="renderMarkdown(message.content)"
|
||||
:content="message.content"
|
||||
:streaming="message.status === 'streaming'"
|
||||
/>
|
||||
|
||||
<div v-if="message.choices.length" class="agent-assistant-choices">
|
||||
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
|
||||
<div class="agent-assistant-choice__bubble">
|
||||
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
|
||||
<div class="agent-assistant-choice__prompt markdown-body" v-html="renderMarkdown(choice.prompt)" />
|
||||
<AgentMarkdownContent :content="choice.prompt" variant="choice" />
|
||||
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
|
||||
<VIcon icon="mdi-check-circle-outline" size="16" />
|
||||
<span>{{
|
||||
|
||||
@@ -11,19 +11,53 @@ const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||
const { themeClasses } = useTheme()
|
||||
const ASSISTANT_PREVIEW_INTERVAL = 125
|
||||
let assistantPreviewTimer: number | null = null
|
||||
let assistantPreviewPendingValue = ''
|
||||
let assistantPreviewLastShownAt = 0
|
||||
let assistantPreviewHasShown = false
|
||||
|
||||
function clearAssistantPreviewTimer() {
|
||||
if (assistantPreviewTimer === null) return
|
||||
|
||||
window.clearTimeout(assistantPreviewTimer)
|
||||
assistantPreviewTimer = null
|
||||
}
|
||||
|
||||
function showPendingAssistantPreview() {
|
||||
assistantPreviewTimer = null
|
||||
if (panelOpen.value || !assistantPreviewPendingValue) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(assistantPreviewPendingValue)
|
||||
assistantPreviewLastShownAt = performance.now()
|
||||
assistantPreviewHasShown = true
|
||||
}
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
assistantPreviewPendingValue = ''
|
||||
clearAssistantPreviewTimer()
|
||||
entryRef.value?.clearBubbles()
|
||||
}
|
||||
|
||||
// 在面板关闭时展示助手回复预览。
|
||||
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局。
|
||||
function handleAssistantPreview(value: string) {
|
||||
if (panelOpen.value) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(value)
|
||||
assistantPreviewPendingValue = value
|
||||
const elapsed = performance.now() - assistantPreviewLastShownAt
|
||||
if (!assistantPreviewHasShown || elapsed >= ASSISTANT_PREVIEW_INTERVAL) {
|
||||
clearAssistantPreviewTimer()
|
||||
showPendingAssistantPreview()
|
||||
return
|
||||
}
|
||||
|
||||
if (assistantPreviewTimer !== null) return
|
||||
assistantPreviewTimer = window.setTimeout(showPendingAssistantPreview, ASSISTANT_PREVIEW_INTERVAL - elapsed)
|
||||
}
|
||||
|
||||
onScopeDispose(clearAssistantPreviewTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { renderAgentMarkdown } from '@/utils/agentMarkdown'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string
|
||||
streaming?: boolean
|
||||
variant?: 'choice' | 'message'
|
||||
}>(),
|
||||
{
|
||||
streaming: false,
|
||||
variant: 'message',
|
||||
},
|
||||
)
|
||||
|
||||
const STREAM_MARKDOWN_RENDER_INTERVAL = 96
|
||||
|
||||
const renderedHtml = shallowRef('')
|
||||
let renderTimer: number | null = null
|
||||
let lastRenderedAt = 0
|
||||
let hasRendered = false
|
||||
|
||||
function clearRenderTimer() {
|
||||
if (renderTimer === null) return
|
||||
|
||||
window.clearTimeout(renderTimer)
|
||||
renderTimer = null
|
||||
}
|
||||
|
||||
// 流式阶段限制 Markdown 全量解析频率;结束时立即渲染最终内容。
|
||||
function renderContent(immediate = false) {
|
||||
const now = performance.now()
|
||||
const elapsed = now - lastRenderedAt
|
||||
if (immediate || !hasRendered || elapsed >= STREAM_MARKDOWN_RENDER_INTERVAL) {
|
||||
clearRenderTimer()
|
||||
renderedHtml.value = renderAgentMarkdown(props.content)
|
||||
lastRenderedAt = now
|
||||
hasRendered = true
|
||||
return
|
||||
}
|
||||
|
||||
if (renderTimer !== null) return
|
||||
renderTimer = window.setTimeout(() => {
|
||||
renderTimer = null
|
||||
renderedHtml.value = renderAgentMarkdown(props.content)
|
||||
lastRenderedAt = performance.now()
|
||||
hasRendered = true
|
||||
}, STREAM_MARKDOWN_RENDER_INTERVAL - elapsed)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
() => renderContent(!props.streaming),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.streaming,
|
||||
streaming => {
|
||||
if (!streaming) renderContent(true)
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(clearRenderTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="markdown-body"
|
||||
:class="variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</template>
|
||||
@@ -68,4 +68,44 @@ describe('AgentAssistantEntry lifecycle motion', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('updates an existing assistant preview without recreating its resize observer', async () => {
|
||||
const observe = vi.fn()
|
||||
const disconnect = vi.fn()
|
||||
const resizeObserverConstructor = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor() {
|
||||
resizeObserverConstructor()
|
||||
}
|
||||
|
||||
observe = observe
|
||||
disconnect = disconnect
|
||||
},
|
||||
)
|
||||
const wrapper = shallowMount(AgentAssistantEntry, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentPetStage: true,
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: true,
|
||||
motionActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.vm.showAssistantReplyPreview('第一段')
|
||||
await nextTick()
|
||||
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.vm.showAssistantReplyPreview('第二段')
|
||||
await nextTick()
|
||||
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('.agent-assistant-fab__bubble').text()).toContain('第二段')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,12 @@ interface MockServerSession {
|
||||
messages: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const agentMarkdownContentStub = {
|
||||
props: ['content', 'variant'],
|
||||
template:
|
||||
"<div :class=\"variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'\">{{ content }}</div>",
|
||||
}
|
||||
|
||||
// 构造符合 Agent 标准响应包装的 fetch 返回值。
|
||||
function createAgentResponse(data: unknown) {
|
||||
return {
|
||||
@@ -111,6 +117,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
@@ -203,6 +210,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
@@ -265,6 +273,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
@@ -298,4 +307,47 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: 'web-agent:coalesced' },
|
||||
...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
return createAgentResponse([])
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('textarea').setValue('测试突发事件')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
|
||||
expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
|
||||
Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
@@ -14,6 +14,7 @@ vi.mock('@/composables/useAppActivityLifecycle', () => ({
|
||||
|
||||
describe('AgentAssistantWidget layering', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body
|
||||
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
|
||||
.forEach(element => element.remove())
|
||||
@@ -50,4 +51,44 @@ describe('AgentAssistantWidget layering', () => {
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
|
||||
})
|
||||
|
||||
it('limits closed-panel assistant preview updates and keeps the latest text', async () => {
|
||||
vi.useFakeTimers()
|
||||
const showAssistantReplyPreview = vi.fn()
|
||||
const entryStub = defineComponent({
|
||||
setup(_props, { expose }) {
|
||||
expose({ clearBubbles: vi.fn(), showAssistantReplyPreview })
|
||||
return () => h('div', { 'data-agent-assistant-entry': '' })
|
||||
},
|
||||
})
|
||||
const panelStub = defineComponent({
|
||||
emits: ['assistant-preview', 'thinking-change', 'update:modelValue'],
|
||||
setup() {
|
||||
return () => h('div', { 'data-agent-assistant-panel': '' })
|
||||
},
|
||||
})
|
||||
const wrapper = mount(AgentAssistantWidget, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentAssistantEntry: entryStub,
|
||||
AgentAssistantPanel: panelStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
const panel = wrapper.findComponent(panelStub)
|
||||
|
||||
panel.vm.$emit('assistant-preview', '第一段')
|
||||
panel.vm.$emit('assistant-preview', '第二段')
|
||||
panel.vm.$emit('assistant-preview', '最终预览')
|
||||
await nextTick()
|
||||
|
||||
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(1)
|
||||
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('第一段')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(2)
|
||||
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('最终预览')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentMarkdownContent from '@/components/agent/AgentMarkdownContent.vue'
|
||||
|
||||
describe('AgentMarkdownContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('throttles streaming Markdown and renders the final content immediately', async () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '**开始**', streaming: true },
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toContain('<strong>开始</strong>')
|
||||
await wrapper.setProps({ content: '**开始继续**' })
|
||||
expect(wrapper.html()).not.toContain('<strong>开始继续</strong>')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(96)
|
||||
expect(wrapper.html()).toContain('<strong>开始继续</strong>')
|
||||
|
||||
await wrapper.setProps({ content: '**最终结果**', streaming: false })
|
||||
expect(wrapper.html()).toContain('<strong>最终结果</strong>')
|
||||
})
|
||||
|
||||
it('escapes raw HTML from Agent output', () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '<img src=x onerror="alert(1)">' },
|
||||
})
|
||||
|
||||
expect(wrapper.html()).not.toContain('<img')
|
||||
expect(wrapper.text()).toContain('<img src=x onerror=')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
const agentMarkdown = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
agentMarkdown.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// Agent 内容来自模型和工具输出,禁用原始 HTML 后统一转换为可展示 Markdown。
|
||||
export function renderAgentMarkdown(content: string) {
|
||||
return content ? agentMarkdown.render(content) : ''
|
||||
}
|
||||
Reference in New Issue
Block a user