From 777e30d137c80e6dfa2679b354c0e60ec91376da Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 1 Aug 2026 08:13:58 +0800 Subject: [PATCH] fix(agent): recover PWA background replies --- src/components/agent/AgentAssistantPanel.vue | 258 ++++++++++++++---- .../__tests__/AgentAssistantPanel.spec.ts | 240 ++++++++++++++++ src/locales/en-US.ts | 1 + src/locales/zh-CN.ts | 1 + src/locales/zh-TW.ts | 1 + 5 files changed, 443 insertions(+), 58 deletions(-) create mode 100644 src/components/agent/__tests__/AgentAssistantPanel.spec.ts diff --git a/src/components/agent/AgentAssistantPanel.vue b/src/components/agent/AgentAssistantPanel.vue index dc547538..b78daae8 100644 --- a/src/components/agent/AgentAssistantPanel.vue +++ b/src/components/agent/AgentAssistantPanel.vue @@ -129,6 +129,16 @@ interface AgentStreamMessageOptions { originalChatId?: string } +interface AgentPendingStreamRecovery { + sessionId: string + startedAt: number + attempts: number +} + +interface AgentStreamReadResult { + receivedTerminalEvent: boolean +} + interface AgentSlashCommand { command: string description: string @@ -204,6 +214,7 @@ const historyHasMore = ref(true) const slashCommands = ref([]) const slashCommandsLoading = ref(false) const slashCommandsLoaded = ref(false) +const pendingStreamRecovery = ref(null) let abortController: AbortController | null = null let mediaRecorder: MediaRecorder | null = null let mediaRecorderStream: MediaStream | null = null @@ -213,12 +224,9 @@ let messageScrollFrame: number | null = null let pendingMessageScrollToBottom = false let streamPersistTimer: number | null = null let userAbortRequested = false +let streamRecoveryAbortRequested = false let streamRecoveryTimer: number | null = null -let pendingStreamRecovery: { - sessionId: string - startedAt: number - attempts: number -} | null = null +let activeStreamStartedAt = 0 const md = new MarkdownIt({ html: true, @@ -234,11 +242,12 @@ md.use(mdLinkAttributes, { }, }) +// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。 +const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value)) const canSend = computed( - () => - (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value, + () => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !isBusy.value && !recording.value, ) -const canRecord = computed(() => !sending.value && !recording.value) +const canRecord = computed(() => !isBusy.value && !recording.value) // 获取当前输入对应的斜杠命令查询词。 const slashCommandQuery = computed(() => { const text = inputText.value.trimStart() @@ -261,13 +270,13 @@ const filteredSlashCommands = computed(() => { const showSlashCommandMenu = computed( () => inputText.value.trimStart().startsWith('/') && - !sending.value && + !isBusy.value && !recording.value && (filteredSlashCommands.value.length > 0 || slashCommandsLoading.value), ) // 根据智能体处理状态切换输入框背景提示。 const inputPlaceholder = computed(() => - sending.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'), + isBusy.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'), ) const recordingTimeText = computed(() => { const seconds = Math.max(0, recordingDuration.value) @@ -776,32 +785,82 @@ function clearStreamRecoveryTimer() { streamRecoveryTimer = null } +// 记录当前流的恢复上下文,并立即持久化,供 PWA 后台冻结或重载后继续轮询。 +function beginStreamRecovery(targetSessionId: string, startedAt: number) { + const current = pendingStreamRecovery.value + pendingStreamRecovery.value = { + sessionId: targetSessionId, + startedAt, + attempts: current?.sessionId === targetSessionId ? current.attempts : 0, + } + persistState({ syncHistory: false }) +} + +// 判断服务端正式会话 ID 或客户端会话 ID 是否属于当前恢复任务。 +function matchesStreamRecoverySession(session: AgentSessionHistoryItem, targetSessionId: string) { + const requestedIds = new Set([sessionId.value, targetSessionId].filter(Boolean)) + + return [session.sessionId, session.clientSessionId].some(id => Boolean(id && requestedIds.has(id))) +} + +// 将无法继续恢复的流式占位转换成明确错误,避免界面永久停留在空白或加载态。 +function failStreamRecovery() { + pendingStreamRecovery.value = null + const assistantMessage = [...messages.value] + .reverse() + .find(message => message.role === 'assistant' && message.status === 'streaming') + if (assistantMessage) { + assistantMessage.status = 'error' + assistantMessage.content ||= t('agentAssistant.recoveryFailed') + markToolsDone(assistantMessage) + refreshMessageList() + } else { + streamError.value = t('agentAssistant.recoveryFailed') + } + persistState() +} + // 从服务端拉取当前会话展示快照,用于移动端后台断开 SSE 后恢复最终结果。 async function restoreCurrentSessionFromServer(targetSessionId: string, startedAt: number) { const session = await loadServerHistorySession(targetSessionId) - const activeSessionIds = new Set([sessionId.value, targetSessionId, session.clientSessionId].filter(Boolean)) - if (!activeSessionIds.has(session.sessionId)) return { restored: false, pending: false } + const activeRecovery = pendingStreamRecovery.value + if (!activeRecovery || activeRecovery.sessionId !== targetSessionId) return { restored: false, pending: false } + if (!matchesStreamRecoverySession(session, targetSessionId)) return { restored: false, pending: false } if (session.updatedAt < startedAt - 1000) return { restored: false, pending: Boolean(session.isProcessing) } - if (!session.messages.length) return { restored: false, pending: Boolean(session.isProcessing) } + if (session.isProcessing) return { restored: false, pending: true } + if (!session.messages.length) return { restored: false, pending: false } - messages.value = normalizeStoredMessages(session.messages) - pendingStreamRecovery = null + const restoredMessages = normalizeStoredMessages(session.messages) + restoredMessages.forEach(message => { + if (message.status !== 'streaming') return + + message.status = 'done' + markToolsDone(message) + }) + messages.value = restoredMessages + sessionId.value = session.sessionId + pendingStreamRecovery.value = null + clearStreamRecoveryTimer() persistState({ syncHistory: false }) scrollToBottom() + if (abortController) { + streamRecoveryAbortRequested = true + abortController.abort() + } return { restored: true, pending: false } } // WebAgent SSE 断流后,后台任务仍会完成并保存快照;前端回到前台后轮询拉取。 function scheduleStreamRecovery(delay = 1200) { - if (!pendingStreamRecovery || typeof window === 'undefined') return + if (!pendingStreamRecovery.value || typeof window === 'undefined') return clearStreamRecoveryTimer() streamRecoveryTimer = window.setTimeout(async () => { streamRecoveryTimer = null - if (!pendingStreamRecovery) return + if (!pendingStreamRecovery.value) return if (document.visibilityState === 'hidden') return - const recovery = pendingStreamRecovery + const recovery = pendingStreamRecovery.value try { const result = await restoreCurrentSessionFromServer(recovery.sessionId, recovery.startedAt) if (result.restored) return @@ -813,17 +872,43 @@ function scheduleStreamRecovery(delay = 1200) { // 会话快照可能还未写入,继续按退避间隔等待。 } - if (!pendingStreamRecovery || pendingStreamRecovery.sessionId !== recovery.sessionId) return - pendingStreamRecovery.attempts += 1 - if (pendingStreamRecovery.attempts > 8) { - pendingStreamRecovery = null + if (!pendingStreamRecovery.value || pendingStreamRecovery.value.sessionId !== recovery.sessionId) return + pendingStreamRecovery.value.attempts += 1 + if (pendingStreamRecovery.value.attempts > 8) { + failStreamRecovery() return } - scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.attempts * 900)) + persistState({ syncHistory: false }) + scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.value.attempts * 900)) }, delay) } +// 从持久化元数据或最后一条 streaming 助手消息重建后台恢复任务。 +function restorePendingStreamRecovery(value: unknown) { + const stored = value && typeof value === 'object' ? (value as Record) : null + const latestAssistantMessage = messages.value.at(-1) + const recoverableAssistantMessage = + latestAssistantMessage?.role === 'assistant' && + (latestAssistantMessage.status === 'streaming' || + (latestAssistantMessage.status === 'done' && isEmptyAssistantMessage(latestAssistantMessage))) + ? latestAssistantMessage + : undefined + const recoverySessionId = stringifyChoiceField(stored?.sessionId) || sessionId.value + const startedAt = Number(stored?.startedAt) || recoverableAssistantMessage?.createdAt || 0 + if (!recoverySessionId || !startedAt || (!stored && !recoverableAssistantMessage)) { + pendingStreamRecovery.value = null + return + } + + if (recoverableAssistantMessage) recoverableAssistantMessage.status = 'streaming' + pendingStreamRecovery.value = { + sessionId: recoverySessionId, + startedAt, + attempts: Math.max(0, Number(stored?.attempts) || 0), + } +} + // 恢复当前会话状态,优先使用本地状态,缺失时使用最近历史。 function restoreState() { try { @@ -843,9 +928,11 @@ function restoreState() { const state = JSON.parse(raw) sessionId.value = state.sessionId || createSessionId() messages.value = normalizeStoredMessages(state.messages) + restorePendingStreamRecovery(state.streamRecovery) upsertCurrentSessionHistory() } catch (error) { sessionId.value = createSessionId() + pendingStreamRecovery.value = null } } @@ -959,6 +1046,7 @@ function persistState(options: { syncHistory?: boolean } = {}) { JSON.stringify({ sessionId: sessionId.value, messages: messages.value.slice(-MAX_PERSISTED_MESSAGES), + streamRecovery: pendingStreamRecovery.value, }), ) } catch (error) { @@ -1223,7 +1311,10 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe markToolsDone(assistantMessage) break case 'start': - if (event.session_id) sessionId.value = event.session_id + if (event.session_id) { + sessionId.value = event.session_id + if (pendingStreamRecovery.value) pendingStreamRecovery.value.sessionId = event.session_id + } break default: break @@ -1248,7 +1339,7 @@ function parseSseBlock(block: string) { } // 读取并应用智能助手 SSE 响应流。 -async function readAgentStream(response: Response, assistantMessage: AgentChatMessage) { +async function readAgentStream(response: Response, assistantMessage: AgentChatMessage): Promise { if (!response.body) { throw new Error(t('agentAssistant.noStream')) } @@ -1256,6 +1347,15 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' + let receivedTerminalEvent = false + + // 应用事件并记录服务端是否明确结束本轮流,区分正常完成与无异常的后台断流。 + const consumeEvent = (event: AgentStreamEvent | null) => { + if (!event) return + + applyStreamEvent(event, assistantMessage) + if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true + } while (true) { const { value, done } = await reader.read() @@ -1266,16 +1366,16 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe buffer = blocks.pop() || '' for (const block of blocks) { - const event = parseSseBlock(block) - if (event) applyStreamEvent(event, assistantMessage) + consumeEvent(parseSseBlock(block)) } } buffer += decoder.decode() if (buffer.trim()) { - const event = parseSseBlock(buffer) - if (event) applyStreamEvent(event, assistantMessage) + consumeEvent(parseSseBlock(buffer)) } + + return { receivedTerminalEvent } } // 移动端浏览器退到后台时,SSE/fetch 可能以 TypeError: Load failed 等形式被动断开。 @@ -1467,7 +1567,9 @@ async function streamAgentMessage( abortController = new AbortController() userAbortRequested = false + streamRecoveryAbortRequested = false const streamStartedAt = Date.now() + activeStreamStartedAt = streamStartedAt let shouldFollowBottomAfterStream = true let shouldSaveClientSnapshot = true @@ -1497,8 +1599,18 @@ async function streamAgentMessage( throw new Error(await resolveAgentResponseErrorMessage(response)) } - await readAgentStream(response, assistantMessage) + const streamResult = await readAgentStream(response, assistantMessage) shouldFollowBottomAfterStream = isMessageScrollerNearBottom() + if (!streamResult.receivedTerminalEvent) { + shouldSaveClientSnapshot = false + beginStreamRecovery(sessionId.value, streamStartedAt) + refreshMessageList() + if (document.visibilityState === 'visible') scheduleStreamRecovery(0) + return + } + + pendingStreamRecovery.value = null + clearStreamRecoveryTimer() if (isEmptyAssistantMessage(assistantMessage)) { messages.value = messages.value.filter(message => message.id !== assistantMessage.id) refreshMessageList() @@ -1510,6 +1622,8 @@ async function streamAgentMessage( refreshMessageList() } } catch (error: any) { + if (error?.name === 'AbortError' && streamRecoveryAbortRequested) return + if (error?.name === 'AbortError' && userAbortRequested) { assistantMessage.status = 'done' markToolsDone(assistantMessage) @@ -1519,13 +1633,8 @@ async function streamAgentMessage( if (isRecoverableStreamDisconnect(error)) { shouldSaveClientSnapshot = false - pendingStreamRecovery = { - sessionId: sessionId.value, - startedAt: streamStartedAt, - attempts: 0, - } - assistantMessage.status = 'done' - markToolsDone(assistantMessage) + beginStreamRecovery(sessionId.value, streamStartedAt) + assistantMessage.status = 'streaming' refreshMessageList() if (document.visibilityState === 'visible') scheduleStreamRecovery(1200) return @@ -1537,7 +1646,9 @@ async function streamAgentMessage( refreshMessageList() } finally { abortController = null + activeStreamStartedAt = 0 userAbortRequested = false + streamRecoveryAbortRequested = false clearStreamPersistTimer() persistState() if (shouldSaveClientSnapshot) { @@ -1556,7 +1667,7 @@ async function streamAgentMessage( async function sendMessage() { const text = inputText.value.trim() const attachments = [...pendingAttachments.value] - if ((!text && !attachments.length) || sending.value) return + if ((!text && !attachments.length) || isBusy.value) return streamError.value = '' inputText.value = '' @@ -1711,7 +1822,7 @@ function toggleVoiceRecording() { // 处理选择按钮点击,保存可读选择描述并把真实值发给 Agent。 async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceCard, button: AgentChoiceButton) { - if (sending.value || choice.status !== 'pending') return + if (isBusy.value || choice.status !== 'pending') return sending.value = true streamError.value = '' @@ -1787,8 +1898,21 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC // 中止当前流式回复。 function stopGeneration() { userAbortRequested = true - pendingStreamRecovery = null + pendingStreamRecovery.value = null clearStreamRecoveryTimer() + const assistantMessage = [...messages.value] + .reverse() + .find(message => message.role === 'assistant' && message.status === 'streaming') + if (assistantMessage) { + if (isEmptyAssistantMessage(assistantMessage)) { + messages.value = messages.value.filter(message => message.id !== assistantMessage.id) + } else { + assistantMessage.status = 'done' + markToolsDone(assistantMessage) + } + refreshMessageList() + } + persistState() if (sessionId.value) { fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, { method: 'POST', @@ -1813,7 +1937,7 @@ function startNewSession() { // 从历史列表恢复指定会话,同时把它设为当前本地会话。 async function loadHistorySession(targetSessionId: string) { - if (sending.value) return + if (isBusy.value) return let historySession = historySessions.value.find(item => item.sessionId === targetSessionId) if (!historySession) return @@ -1837,7 +1961,7 @@ async function loadHistorySession(targetSessionId: string) { // 删除指定历史会话;若删除的是当前会话,则切换到新的空会话。 async function deleteHistorySession(targetSessionId: string) { - if (sending.value && targetSessionId === sessionId.value) return + if (isBusy.value && targetSessionId === sessionId.value) return try { await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, { @@ -1906,8 +2030,23 @@ function handleGlobalKeydown(event: KeyboardEvent) { if (event.key === 'Escape' && isOpen.value) closeDrawer() } -// 页面从后台恢复时尝试拉取 WebAgent 后台完成后的会话快照。 +// 页面进入后台时保存流式占位,恢复可见时尝试拉取 WebAgent 后台完成后的会话快照。 function handleVisibilityChange() { + if (document.visibilityState === 'hidden') { + if (sending.value && activeStreamStartedAt && sessionId.value) { + beginStreamRecovery(sessionId.value, activeStreamStartedAt) + } else if (pendingStreamRecovery.value) { + clearStreamPersistTimer() + persistState({ syncHistory: false }) + } + return + } + + scheduleStreamRecovery(0) +} + +// PWA 从页面缓存或系统挂起状态返回时补触发恢复,兼容未派发 visibilitychange 的浏览器。 +function handlePageShow() { if (document.visibilityState === 'visible') scheduleStreamRecovery(0) } @@ -1927,14 +2066,16 @@ watch(isOpen, open => { if (open) scrollToBottom() }) -watch(sending, value => emit('thinking-change', value), { immediate: true }) +watch(isBusy, value => emit('thinking-change', value), { immediate: true }) onMounted(() => { restoreHistorySessions() restoreState() loadServerHistorySessions() + if (pendingStreamRecovery.value && document.visibilityState === 'visible') scheduleStreamRecovery(0) syncInputHeight() window.addEventListener('keydown', handleGlobalKeydown) + window.addEventListener('pageshow', handlePageShow) document.addEventListener('visibilitychange', handleVisibilityChange) }) @@ -1948,6 +2089,7 @@ onScopeDispose(() => { if (typeof window === 'undefined') return window.removeEventListener('keydown', handleGlobalKeydown) + window.removeEventListener('pageshow', handlePageShow) document.removeEventListener('visibilitychange', handleVisibilityChange) }) @@ -1980,7 +2122,7 @@ onScopeDispose(() => {
{{ t('agentAssistant.title') }}
- {{ sending ? t('agentAssistant.thinking') : t('agentAssistant.ready') }} + {{ isBusy ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
@@ -2030,7 +2172,7 @@ onScopeDispose(() => { class="agent-assistant-history-item" :class="{ 'is-active': isCurrentHistorySession(historySession.sessionId) }" type="button" - :disabled="sending" + :disabled="isBusy" @click="loadHistorySession(historySession.sessionId)" > @@ -2044,7 +2186,7 @@ onScopeDispose(() => { { { class="agent-assistant-choice__button" size="small" variant="flat" - :disabled="sending || choice.status !== 'pending'" + :disabled="isBusy || choice.status !== 'pending'" @click="handleChoiceClick(message, choice, button)" > {{ button.label }} @@ -2259,7 +2401,7 @@ onScopeDispose(() => { { class="agent-assistant-file-input" type="file" multiple - :disabled="sending" + :disabled="isBusy" @change="handleFileSelection" /> { v-model="inputText" class="agent-assistant-textarea" rows="1" - :disabled="sending || recording" + :disabled="isBusy || recording" :placeholder="inputPlaceholder" @input="handleInputChange" @keydown="handleInputKeydown" @@ -2333,12 +2475,12 @@ onScopeDispose(() => { - + diff --git a/src/components/agent/__tests__/AgentAssistantPanel.spec.ts b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts new file mode 100644 index 00000000..aeae5a3f --- /dev/null +++ b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts @@ -0,0 +1,240 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock('vuetify', () => ({ + useDisplay: () => ({ mdAndDown: { value: true } }), +})) + +vi.mock('@/stores', () => ({ + useAuthStore: () => ({ token: null }), + useUserStore: () => ({ getUserName: 'Tester' }), +})) + +vi.mock('@/plugins/i18n', () => ({ + getCurrentLocale: () => 'zh-CN', +})) + +interface MockServerSession { + session_id: string + client_session_id: string + updated_at: string + is_processing: boolean + messages: Array> +} + +// 构造符合 Agent 标准响应包装的 fetch 返回值。 +function createAgentResponse(data: unknown) { + return { + json: vi.fn().mockResolvedValue({ success: true, data }), + ok: true, + } as unknown as Response +} + +describe('AgentAssistantPanel stream recovery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('restores a legacy empty message to loading and waits for the final reply after a PWA reload', async () => { + const startedAt = Date.now() - 5000 + const localSessionId = 'web-local-session' + const serverSessionId = 'web-agent:server-session' + const userMessage = { + id: 'user-1', + role: 'user', + content: '检查后台任务', + createdAt: startedAt - 100, + status: 'done', + attachments: [], + choices: [], + tools: [], + } + const assistantPlaceholder = { + id: 'assistant-1', + role: 'assistant', + content: '', + createdAt: startedAt, + status: 'done', + attachments: [], + choices: [], + tools: [], + } + const processingSession: MockServerSession = { + session_id: serverSessionId, + client_session_id: localSessionId, + updated_at: new Date(startedAt + 1000).toISOString(), + is_processing: true, + messages: [userMessage], + } + const completedSession: MockServerSession = { + ...processingSession, + updated_at: new Date(startedAt + 2000).toISOString(), + is_processing: false, + messages: [ + userMessage, + { + ...assistantPlaceholder, + content: '后台任务已经完成', + createdAt: startedAt + 2000, + status: 'done', + }, + ], + } + const detailResponses = [processingSession, completedSession] + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes(`/sessions/${localSessionId}`)) { + return createAgentResponse(detailResponses.shift() || completedSession) + } + + return createAgentResponse([]) + }) + vi.stubGlobal('fetch', fetchMock) + localStorage.setItem( + 'moviepilot-agent-assistant-state', + JSON.stringify({ + sessionId: localSessionId, + messages: [userMessage, assistantPlaceholder], + }), + ) + + const wrapper = shallowMount(AgentAssistantPanel, { + props: { modelValue: true }, + global: { + stubs: { + IconBtn: { template: '' }, + PerfectScrollbar: { template: '
' }, + VIcon: true, + }, + }, + }) + + await vi.advanceTimersByTimeAsync(0) + await flushPromises() + + expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true) + expect(wrapper.text()).toContain('agentAssistant.thinking') + expect( + fetchMock.mock.calls.filter(([input]) => String(input).includes(`/sessions/${localSessionId}`)), + ).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(1200) + await flushPromises() + + expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false) + expect(wrapper.text()).toContain('后台任务已经完成') + expect(wrapper.text()).toContain('agentAssistant.ready') + expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({ + sessionId: serverSessionId, + streamRecovery: null, + }) + + wrapper.unmount() + }) + + it('persists an active stream in the background and restores it when the PWA becomes visible', async () => { + let visibilityState: DocumentVisibilityState = 'visible' + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState) + const startedAt = Date.now() + const localSessionId = 'web-local-active' + const serverSessionId = 'web-agent:server-active' + const userMessage = { + id: 'user-active', + role: 'user', + content: '继续后台处理', + createdAt: startedAt, + status: 'done', + attachments: [], + choices: [], + tools: [], + } + const completedSession: MockServerSession = { + session_id: serverSessionId, + client_session_id: localSessionId, + updated_at: new Date(startedAt + 2000).toISOString(), + is_processing: false, + messages: [ + userMessage, + { + id: 'assistant-active', + role: 'assistant', + content: '前后台恢复成功', + createdAt: startedAt + 2000, + status: 'done', + attachments: [], + choices: [], + tools: [], + }, + ], + } + const encoder = new TextEncoder() + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/message/agent/stream')) { + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'start', session_id: serverSessionId })}\n\n`), + ) + init?.signal?.addEventListener('abort', () => { + controller.error(new DOMException('Aborted', 'AbortError')) + }) + }, + }) + + return new Response(body, { status: 200 }) + } + if (url.includes(`/sessions/${encodeURIComponent(serverSessionId)}`)) return createAgentResponse(completedSession) + + return createAgentResponse([]) + }) + vi.stubGlobal('fetch', fetchMock) + + const wrapper = shallowMount(AgentAssistantPanel, { + props: { modelValue: true }, + global: { + stubs: { + IconBtn: { template: '' }, + PerfectScrollbar: { template: '
' }, + VIcon: true, + }, + }, + }) + const textarea = wrapper.find('textarea') + await textarea.setValue('继续后台处理') + await textarea.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true) + visibilityState = 'hidden' + document.dispatchEvent(new Event('visibilitychange')) + await flushPromises() + + expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({ + sessionId: serverSessionId, + streamRecovery: { + sessionId: serverSessionId, + }, + }) + + visibilityState = 'visible' + document.dispatchEvent(new Event('visibilitychange')) + await vi.advanceTimersByTimeAsync(0) + await flushPromises() + + expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false) + expect(wrapper.text()).toContain('前后台恢复成功') + expect(wrapper.text()).toContain('agentAssistant.ready') + + wrapper.unmount() + }) +}) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 015a25d5..a3c3a385 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -790,6 +790,7 @@ export default { choiceExpired: 'This choice expired. Please ask again.', error: 'Assistant response failed', noStream: 'This browser cannot read streaming responses', + recoveryFailed: 'Failed to recover the background response. Please try again later.', }, workflow: { components: 'Action Components', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index f414f941..b7b6e452 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -780,6 +780,7 @@ export default { choiceExpired: '该选择已失效,请重新发起选择', error: '智能助手响应失败', noStream: '当前浏览器无法读取流式响应', + recoveryFailed: '后台回复恢复失败,请稍后重试', }, workflow: { components: '动作组件', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index d0e7f68b..b50f6771 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -780,6 +780,7 @@ export default { choiceExpired: '該選擇已失效,請重新發起選擇', error: '智能助手響應失敗', noStream: '目前瀏覽器無法讀取串流響應', + recoveryFailed: '後台回覆恢復失敗,請稍後重試', }, workflow: { components: '動作組件',