mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
perf(agent): smooth SSE streaming updates
This commit is contained in:
+3
-2
@@ -65,13 +65,14 @@ http {
|
|||||||
root html;
|
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类型设置
|
# SSE MIME类型设置
|
||||||
default_type text/event-stream;
|
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;
|
add_header X-Accel-Buffering no;
|
||||||
|
gzip off;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ASSISTANT_PREVIEW_MAX_LENGTH = 480
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
open: []
|
open: []
|
||||||
}>()
|
}>()
|
||||||
@@ -1014,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
|
|||||||
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
|
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
|
||||||
if (!props.active || !bubble.text) return
|
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 hadBubbles = hasFabBubbles.value
|
||||||
const wasDocked = fabDocked.value
|
const wasDocked = fabDocked.value
|
||||||
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
|
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
|
||||||
@@ -1061,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
|
|||||||
showBubble({
|
showBubble({
|
||||||
id: 'assistant-preview',
|
id: 'assistant-preview',
|
||||||
kind: 'assistant',
|
kind: 'assistant',
|
||||||
text: value,
|
text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MarkdownIt from 'markdown-it'
|
|
||||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore, useUserStore } from '@/stores'
|
import { useAuthStore, useUserStore } from '@/stores'
|
||||||
import { getCurrentLocale } from '@/plugins/i18n'
|
import { getCurrentLocale } from '@/plugins/i18n'
|
||||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||||
|
import AgentMarkdownContent from './AgentMarkdownContent.vue'
|
||||||
|
|
||||||
type AgentMessageRole = 'user' | 'assistant'
|
type AgentMessageRole = 'user' | 'assistant'
|
||||||
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
||||||
@@ -240,25 +239,16 @@ let recordingChunks: BlobPart[] = []
|
|||||||
let messageScrollFrame: number | null = null
|
let messageScrollFrame: number | null = null
|
||||||
let pendingMessageScrollToBottom = false
|
let pendingMessageScrollToBottom = false
|
||||||
let streamPersistTimer: number | null = null
|
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 userAbortRequested = false
|
||||||
let streamRecoveryAbortRequested = false
|
let streamRecoveryAbortRequested = false
|
||||||
let streamRecoveryTimer: number | null = null
|
let streamRecoveryTimer: number | null = null
|
||||||
let activeStreamStartedAt = 0
|
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 isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||||
const canSend = computed(
|
const canSend = computed(
|
||||||
@@ -1105,12 +1095,6 @@ function persistState(options: { syncHistory?: boolean } = {}) {
|
|||||||
if (syncHistory) upsertCurrentSessionHistory()
|
if (syncHistory) upsertCurrentSessionHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染助手消息中的 Markdown 文本。
|
|
||||||
function renderMarkdown(value: string) {
|
|
||||||
if (!value) return ''
|
|
||||||
return md.render(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拼接后端 API 地址。
|
// 拼接后端 API 地址。
|
||||||
function resolveApiUrl(path: string) {
|
function resolveApiUrl(path: string) {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
|
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
|
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只在滚动事件中更新自动跟随意图,避免每个流式事件触发布局读取。
|
||||||
|
function handleMessageScrollerScroll() {
|
||||||
|
messageScrollerShouldFollow = isMessageScrollerNearBottom()
|
||||||
|
}
|
||||||
|
|
||||||
// 合并滚动更新请求,降低流式输出时的布局测量频率。
|
// 合并滚动更新请求,降低流式输出时的布局测量频率。
|
||||||
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
||||||
const { toBottom = false } = options
|
const { toBottom = false } = options
|
||||||
@@ -1174,6 +1163,7 @@ function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
|||||||
// 将消息列表滚动到底部。
|
// 将消息列表滚动到底部。
|
||||||
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
||||||
const { smooth = false } = options
|
const { smooth = false } = options
|
||||||
|
messageScrollerShouldFollow = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const scroller = getMessageScrollerElement()
|
const scroller = getMessageScrollerElement()
|
||||||
if (!scroller) return
|
if (!scroller) return
|
||||||
@@ -1217,13 +1207,17 @@ function clearMessageScrollFrame() {
|
|||||||
pendingMessageScrollToBottom = false
|
pendingMessageScrollToBottom = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟持久化流式消息,避免每个 token 都写入本地存储。
|
// 流式期间至多每秒保存一次轻量当前态,终态再同步完整历史。
|
||||||
function scheduleStreamPersist() {
|
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(() => {
|
streamPersistTimer = window.setTimeout(() => {
|
||||||
persistState()
|
|
||||||
streamPersistTimer = null
|
streamPersistTimer = null
|
||||||
}, STREAM_STATE_PERSIST_DELAY)
|
streamPersistLastRunAt = Date.now()
|
||||||
|
persistState({ syncHistory: false })
|
||||||
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步输入框高度,使多行输入不撑破底部布局。
|
// 同步输入框高度,使多行输入不撑破底部布局。
|
||||||
@@ -1351,8 +1345,6 @@ function applyMessageUpdate(event: AgentStreamEvent) {
|
|||||||
|
|
||||||
// 将单个 SSE 事件应用到正在流式输出的助手消息。
|
// 将单个 SSE 事件应用到正在流式输出的助手消息。
|
||||||
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
||||||
const shouldFollowBottom = isMessageScrollerNearBottom()
|
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'delta':
|
case 'delta':
|
||||||
appendAssistantTextSegment(assistantMessage, event.content || '')
|
appendAssistantTextSegment(assistantMessage, event.content || '')
|
||||||
@@ -1410,10 +1402,54 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
|||||||
|
|
||||||
scheduleStreamPersist()
|
scheduleStreamPersist()
|
||||||
nextTick(() => {
|
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 数据块。
|
// 解析一个 SSE 数据块。
|
||||||
function parseSseBlock(block: string) {
|
function parseSseBlock(block: string) {
|
||||||
const data = block
|
const data = block
|
||||||
@@ -1441,26 +1477,30 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
|||||||
const consumeEvent = (event: AgentStreamEvent | null) => {
|
const consumeEvent = (event: AgentStreamEvent | null) => {
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
|
||||||
applyStreamEvent(event, assistantMessage)
|
queueStreamEvent(event, assistantMessage)
|
||||||
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
|
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
const { value, done } = await reader.read()
|
while (true) {
|
||||||
if (done) break
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
const blocks = buffer.split(/\n\n/)
|
const blocks = buffer.split(/\r?\n\r?\n/)
|
||||||
buffer = blocks.pop() || ''
|
buffer = blocks.pop() || ''
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
consumeEvent(parseSseBlock(block))
|
consumeEvent(parseSseBlock(block))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode()
|
buffer += decoder.decode()
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
consumeEvent(parseSseBlock(buffer))
|
consumeEvent(parseSseBlock(buffer))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
flushPendingStreamDelta()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { receivedTerminalEvent }
|
return { receivedTerminalEvent }
|
||||||
@@ -2172,6 +2212,7 @@ onScopeDispose(clearPendingAttachments)
|
|||||||
onScopeDispose(cancelVoiceRecording)
|
onScopeDispose(cancelVoiceRecording)
|
||||||
onScopeDispose(clearMessageScrollFrame)
|
onScopeDispose(clearMessageScrollFrame)
|
||||||
onScopeDispose(clearStreamPersistTimer)
|
onScopeDispose(clearStreamPersistTimer)
|
||||||
|
onScopeDispose(clearPendingStreamDelta)
|
||||||
onScopeDispose(clearStreamRecoveryTimer)
|
onScopeDispose(clearStreamRecoveryTimer)
|
||||||
onScopeDispose(() => {
|
onScopeDispose(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -2312,6 +2353,7 @@ onScopeDispose(() => {
|
|||||||
ref="messageListRef"
|
ref="messageListRef"
|
||||||
class="agent-assistant-messages"
|
class="agent-assistant-messages"
|
||||||
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
|
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
|
||||||
|
@scroll.passive="handleMessageScrollerScroll"
|
||||||
>
|
>
|
||||||
<div class="agent-assistant-messages__content">
|
<div class="agent-assistant-messages__content">
|
||||||
<div v-if="!hasMessages" class="agent-assistant-empty">
|
<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">
|
<div v-if="message.role === 'assistant' && message.segments.length" class="agent-assistant-segments">
|
||||||
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
|
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
|
||||||
<div
|
<AgentMarkdownContent
|
||||||
v-if="segment.type === 'text'"
|
v-if="segment.type === 'text'"
|
||||||
class="agent-assistant-message__bubble markdown-body"
|
:content="segment.content"
|
||||||
v-html="renderMarkdown(segment.content)"
|
:streaming="message.status === 'streaming'"
|
||||||
/>
|
/>
|
||||||
<div v-else class="agent-assistant-tool">
|
<div v-else class="agent-assistant-tool">
|
||||||
<VIcon
|
<VIcon
|
||||||
@@ -2354,17 +2396,17 @@ onScopeDispose(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<AgentMarkdownContent
|
||||||
v-else-if="message.content"
|
v-else-if="message.content"
|
||||||
class="agent-assistant-message__bubble markdown-body"
|
:content="message.content"
|
||||||
v-html="renderMarkdown(message.content)"
|
:streaming="message.status === 'streaming'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="message.choices.length" class="agent-assistant-choices">
|
<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 v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
|
||||||
<div class="agent-assistant-choice__bubble">
|
<div class="agent-assistant-choice__bubble">
|
||||||
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
|
<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">
|
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
|
||||||
<VIcon icon="mdi-check-circle-outline" size="16" />
|
<VIcon icon="mdi-check-circle-outline" size="16" />
|
||||||
<span>{{
|
<span>{{
|
||||||
|
|||||||
@@ -11,19 +11,53 @@ const thinking = ref(false)
|
|||||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||||
const { themeClasses } = useTheme()
|
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 面板并清空入口预览气泡。
|
// 打开 Agent 面板并清空入口预览气泡。
|
||||||
function openPanel() {
|
function openPanel() {
|
||||||
panelOpen.value = true
|
panelOpen.value = true
|
||||||
|
assistantPreviewPendingValue = ''
|
||||||
|
clearAssistantPreviewTimer()
|
||||||
entryRef.value?.clearBubbles()
|
entryRef.value?.clearBubbles()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在面板关闭时展示助手回复预览。
|
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局。
|
||||||
function handleAssistantPreview(value: string) {
|
function handleAssistantPreview(value: string) {
|
||||||
if (panelOpen.value) return
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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()
|
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>>
|
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 返回值。
|
// 构造符合 Agent 标准响应包装的 fetch 返回值。
|
||||||
function createAgentResponse(data: unknown) {
|
function createAgentResponse(data: unknown) {
|
||||||
return {
|
return {
|
||||||
@@ -111,6 +117,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
props: { modelValue: true },
|
props: { modelValue: true },
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
AgentMarkdownContent: agentMarkdownContentStub,
|
||||||
IconBtn: { template: '<button><slot /></button>' },
|
IconBtn: { template: '<button><slot /></button>' },
|
||||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||||
VIcon: true,
|
VIcon: true,
|
||||||
@@ -203,6 +210,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
props: { modelValue: true },
|
props: { modelValue: true },
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
AgentMarkdownContent: agentMarkdownContentStub,
|
||||||
IconBtn: { template: '<button><slot /></button>' },
|
IconBtn: { template: '<button><slot /></button>' },
|
||||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||||
VIcon: true,
|
VIcon: true,
|
||||||
@@ -265,6 +273,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
props: { modelValue: true },
|
props: { modelValue: true },
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
AgentMarkdownContent: agentMarkdownContentStub,
|
||||||
IconBtn: { template: '<button><slot /></button>' },
|
IconBtn: { template: '<button><slot /></button>' },
|
||||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||||
VIcon: true,
|
VIcon: true,
|
||||||
@@ -298,4 +307,47 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
|
|
||||||
wrapper.unmount()
|
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 { 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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||||
@@ -14,6 +14,7 @@ vi.mock('@/composables/useAppActivityLifecycle', () => ({
|
|||||||
|
|
||||||
describe('AgentAssistantWidget layering', () => {
|
describe('AgentAssistantWidget layering', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
document.body
|
document.body
|
||||||
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
|
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
|
||||||
.forEach(element => element.remove())
|
.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.panel).toBe(2_147_483_646)
|
||||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
|
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