mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
fix(agent): recover PWA background replies
This commit is contained in:
@@ -129,6 +129,16 @@ interface AgentStreamMessageOptions {
|
|||||||
originalChatId?: string
|
originalChatId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentPendingStreamRecovery {
|
||||||
|
sessionId: string
|
||||||
|
startedAt: number
|
||||||
|
attempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentStreamReadResult {
|
||||||
|
receivedTerminalEvent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface AgentSlashCommand {
|
interface AgentSlashCommand {
|
||||||
command: string
|
command: string
|
||||||
description: string
|
description: string
|
||||||
@@ -204,6 +214,7 @@ const historyHasMore = ref(true)
|
|||||||
const slashCommands = ref<AgentSlashCommand[]>([])
|
const slashCommands = ref<AgentSlashCommand[]>([])
|
||||||
const slashCommandsLoading = ref(false)
|
const slashCommandsLoading = ref(false)
|
||||||
const slashCommandsLoaded = ref(false)
|
const slashCommandsLoaded = ref(false)
|
||||||
|
const pendingStreamRecovery = ref<AgentPendingStreamRecovery | null>(null)
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
let mediaRecorder: MediaRecorder | null = null
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
let mediaRecorderStream: MediaStream | null = null
|
let mediaRecorderStream: MediaStream | null = null
|
||||||
@@ -213,12 +224,9 @@ let messageScrollFrame: number | null = null
|
|||||||
let pendingMessageScrollToBottom = false
|
let pendingMessageScrollToBottom = false
|
||||||
let streamPersistTimer: number | null = null
|
let streamPersistTimer: number | null = null
|
||||||
let userAbortRequested = false
|
let userAbortRequested = false
|
||||||
|
let streamRecoveryAbortRequested = false
|
||||||
let streamRecoveryTimer: number | null = null
|
let streamRecoveryTimer: number | null = null
|
||||||
let pendingStreamRecovery: {
|
let activeStreamStartedAt = 0
|
||||||
sessionId: string
|
|
||||||
startedAt: number
|
|
||||||
attempts: number
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: true,
|
html: true,
|
||||||
@@ -234,11 +242,12 @@ md.use(mdLinkAttributes, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||||||
|
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||||
const canSend = computed(
|
const canSend = computed(
|
||||||
() =>
|
() => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !isBusy.value && !recording.value,
|
||||||
(inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value,
|
|
||||||
)
|
)
|
||||||
const canRecord = computed(() => !sending.value && !recording.value)
|
const canRecord = computed(() => !isBusy.value && !recording.value)
|
||||||
// 获取当前输入对应的斜杠命令查询词。
|
// 获取当前输入对应的斜杠命令查询词。
|
||||||
const slashCommandQuery = computed(() => {
|
const slashCommandQuery = computed(() => {
|
||||||
const text = inputText.value.trimStart()
|
const text = inputText.value.trimStart()
|
||||||
@@ -261,13 +270,13 @@ const filteredSlashCommands = computed(() => {
|
|||||||
const showSlashCommandMenu = computed(
|
const showSlashCommandMenu = computed(
|
||||||
() =>
|
() =>
|
||||||
inputText.value.trimStart().startsWith('/') &&
|
inputText.value.trimStart().startsWith('/') &&
|
||||||
!sending.value &&
|
!isBusy.value &&
|
||||||
!recording.value &&
|
!recording.value &&
|
||||||
(filteredSlashCommands.value.length > 0 || slashCommandsLoading.value),
|
(filteredSlashCommands.value.length > 0 || slashCommandsLoading.value),
|
||||||
)
|
)
|
||||||
// 根据智能体处理状态切换输入框背景提示。
|
// 根据智能体处理状态切换输入框背景提示。
|
||||||
const inputPlaceholder = computed(() =>
|
const inputPlaceholder = computed(() =>
|
||||||
sending.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'),
|
isBusy.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'),
|
||||||
)
|
)
|
||||||
const recordingTimeText = computed(() => {
|
const recordingTimeText = computed(() => {
|
||||||
const seconds = Math.max(0, recordingDuration.value)
|
const seconds = Math.max(0, recordingDuration.value)
|
||||||
@@ -776,32 +785,82 @@ function clearStreamRecoveryTimer() {
|
|||||||
streamRecoveryTimer = null
|
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 后恢复最终结果。
|
// 从服务端拉取当前会话展示快照,用于移动端后台断开 SSE 后恢复最终结果。
|
||||||
async function restoreCurrentSessionFromServer(targetSessionId: string, startedAt: number) {
|
async function restoreCurrentSessionFromServer(targetSessionId: string, startedAt: number) {
|
||||||
const session = await loadServerHistorySession(targetSessionId)
|
const session = await loadServerHistorySession(targetSessionId)
|
||||||
const activeSessionIds = new Set([sessionId.value, targetSessionId, session.clientSessionId].filter(Boolean))
|
const activeRecovery = pendingStreamRecovery.value
|
||||||
if (!activeSessionIds.has(session.sessionId)) return { restored: false, pending: false }
|
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.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)
|
const restoredMessages = normalizeStoredMessages(session.messages)
|
||||||
pendingStreamRecovery = null
|
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 })
|
persistState({ syncHistory: false })
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
if (abortController) {
|
||||||
|
streamRecoveryAbortRequested = true
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
return { restored: true, pending: false }
|
return { restored: true, pending: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAgent SSE 断流后,后台任务仍会完成并保存快照;前端回到前台后轮询拉取。
|
// WebAgent SSE 断流后,后台任务仍会完成并保存快照;前端回到前台后轮询拉取。
|
||||||
function scheduleStreamRecovery(delay = 1200) {
|
function scheduleStreamRecovery(delay = 1200) {
|
||||||
if (!pendingStreamRecovery || typeof window === 'undefined') return
|
if (!pendingStreamRecovery.value || typeof window === 'undefined') return
|
||||||
|
|
||||||
clearStreamRecoveryTimer()
|
clearStreamRecoveryTimer()
|
||||||
streamRecoveryTimer = window.setTimeout(async () => {
|
streamRecoveryTimer = window.setTimeout(async () => {
|
||||||
streamRecoveryTimer = null
|
streamRecoveryTimer = null
|
||||||
if (!pendingStreamRecovery) return
|
if (!pendingStreamRecovery.value) return
|
||||||
if (document.visibilityState === 'hidden') return
|
if (document.visibilityState === 'hidden') return
|
||||||
|
|
||||||
const recovery = pendingStreamRecovery
|
const recovery = pendingStreamRecovery.value
|
||||||
try {
|
try {
|
||||||
const result = await restoreCurrentSessionFromServer(recovery.sessionId, recovery.startedAt)
|
const result = await restoreCurrentSessionFromServer(recovery.sessionId, recovery.startedAt)
|
||||||
if (result.restored) return
|
if (result.restored) return
|
||||||
@@ -813,17 +872,43 @@ function scheduleStreamRecovery(delay = 1200) {
|
|||||||
// 会话快照可能还未写入,继续按退避间隔等待。
|
// 会话快照可能还未写入,继续按退避间隔等待。
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingStreamRecovery || pendingStreamRecovery.sessionId !== recovery.sessionId) return
|
if (!pendingStreamRecovery.value || pendingStreamRecovery.value.sessionId !== recovery.sessionId) return
|
||||||
pendingStreamRecovery.attempts += 1
|
pendingStreamRecovery.value.attempts += 1
|
||||||
if (pendingStreamRecovery.attempts > 8) {
|
if (pendingStreamRecovery.value.attempts > 8) {
|
||||||
pendingStreamRecovery = null
|
failStreamRecovery()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.attempts * 900))
|
persistState({ syncHistory: false })
|
||||||
|
scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.value.attempts * 900))
|
||||||
}, delay)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从持久化元数据或最后一条 streaming 助手消息重建后台恢复任务。
|
||||||
|
function restorePendingStreamRecovery(value: unknown) {
|
||||||
|
const stored = value && typeof value === 'object' ? (value as Record<string, unknown>) : 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() {
|
function restoreState() {
|
||||||
try {
|
try {
|
||||||
@@ -843,9 +928,11 @@ function restoreState() {
|
|||||||
const state = JSON.parse(raw)
|
const state = JSON.parse(raw)
|
||||||
sessionId.value = state.sessionId || createSessionId()
|
sessionId.value = state.sessionId || createSessionId()
|
||||||
messages.value = normalizeStoredMessages(state.messages)
|
messages.value = normalizeStoredMessages(state.messages)
|
||||||
|
restorePendingStreamRecovery(state.streamRecovery)
|
||||||
upsertCurrentSessionHistory()
|
upsertCurrentSessionHistory()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sessionId.value = createSessionId()
|
sessionId.value = createSessionId()
|
||||||
|
pendingStreamRecovery.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,6 +1046,7 @@ function persistState(options: { syncHistory?: boolean } = {}) {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
sessionId: sessionId.value,
|
sessionId: sessionId.value,
|
||||||
messages: messages.value.slice(-MAX_PERSISTED_MESSAGES),
|
messages: messages.value.slice(-MAX_PERSISTED_MESSAGES),
|
||||||
|
streamRecovery: pendingStreamRecovery.value,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1223,7 +1311,10 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
|||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
break
|
break
|
||||||
case 'start':
|
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
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -1248,7 +1339,7 @@ function parseSseBlock(block: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 读取并应用智能助手 SSE 响应流。
|
// 读取并应用智能助手 SSE 响应流。
|
||||||
async function readAgentStream(response: Response, assistantMessage: AgentChatMessage) {
|
async function readAgentStream(response: Response, assistantMessage: AgentChatMessage): Promise<AgentStreamReadResult> {
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new Error(t('agentAssistant.noStream'))
|
throw new Error(t('agentAssistant.noStream'))
|
||||||
}
|
}
|
||||||
@@ -1256,6 +1347,15 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
|||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
const decoder = new TextDecoder('utf-8')
|
const decoder = new TextDecoder('utf-8')
|
||||||
let buffer = ''
|
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) {
|
while (true) {
|
||||||
const { value, done } = await reader.read()
|
const { value, done } = await reader.read()
|
||||||
@@ -1266,16 +1366,16 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
|||||||
buffer = blocks.pop() || ''
|
buffer = blocks.pop() || ''
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const event = parseSseBlock(block)
|
consumeEvent(parseSseBlock(block))
|
||||||
if (event) applyStreamEvent(event, assistantMessage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer += decoder.decode()
|
buffer += decoder.decode()
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
const event = parseSseBlock(buffer)
|
consumeEvent(parseSseBlock(buffer))
|
||||||
if (event) applyStreamEvent(event, assistantMessage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { receivedTerminalEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端浏览器退到后台时,SSE/fetch 可能以 TypeError: Load failed 等形式被动断开。
|
// 移动端浏览器退到后台时,SSE/fetch 可能以 TypeError: Load failed 等形式被动断开。
|
||||||
@@ -1467,7 +1567,9 @@ async function streamAgentMessage(
|
|||||||
|
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
userAbortRequested = false
|
userAbortRequested = false
|
||||||
|
streamRecoveryAbortRequested = false
|
||||||
const streamStartedAt = Date.now()
|
const streamStartedAt = Date.now()
|
||||||
|
activeStreamStartedAt = streamStartedAt
|
||||||
let shouldFollowBottomAfterStream = true
|
let shouldFollowBottomAfterStream = true
|
||||||
let shouldSaveClientSnapshot = true
|
let shouldSaveClientSnapshot = true
|
||||||
|
|
||||||
@@ -1497,8 +1599,18 @@ async function streamAgentMessage(
|
|||||||
throw new Error(await resolveAgentResponseErrorMessage(response))
|
throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
await readAgentStream(response, assistantMessage)
|
const streamResult = await readAgentStream(response, assistantMessage)
|
||||||
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
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)) {
|
if (isEmptyAssistantMessage(assistantMessage)) {
|
||||||
messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
|
messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
@@ -1510,6 +1622,8 @@ async function streamAgentMessage(
|
|||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.name === 'AbortError' && streamRecoveryAbortRequested) return
|
||||||
|
|
||||||
if (error?.name === 'AbortError' && userAbortRequested) {
|
if (error?.name === 'AbortError' && userAbortRequested) {
|
||||||
assistantMessage.status = 'done'
|
assistantMessage.status = 'done'
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
@@ -1519,13 +1633,8 @@ async function streamAgentMessage(
|
|||||||
|
|
||||||
if (isRecoverableStreamDisconnect(error)) {
|
if (isRecoverableStreamDisconnect(error)) {
|
||||||
shouldSaveClientSnapshot = false
|
shouldSaveClientSnapshot = false
|
||||||
pendingStreamRecovery = {
|
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||||||
sessionId: sessionId.value,
|
assistantMessage.status = 'streaming'
|
||||||
startedAt: streamStartedAt,
|
|
||||||
attempts: 0,
|
|
||||||
}
|
|
||||||
assistantMessage.status = 'done'
|
|
||||||
markToolsDone(assistantMessage)
|
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
if (document.visibilityState === 'visible') scheduleStreamRecovery(1200)
|
if (document.visibilityState === 'visible') scheduleStreamRecovery(1200)
|
||||||
return
|
return
|
||||||
@@ -1537,7 +1646,9 @@ async function streamAgentMessage(
|
|||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
} finally {
|
} finally {
|
||||||
abortController = null
|
abortController = null
|
||||||
|
activeStreamStartedAt = 0
|
||||||
userAbortRequested = false
|
userAbortRequested = false
|
||||||
|
streamRecoveryAbortRequested = false
|
||||||
clearStreamPersistTimer()
|
clearStreamPersistTimer()
|
||||||
persistState()
|
persistState()
|
||||||
if (shouldSaveClientSnapshot) {
|
if (shouldSaveClientSnapshot) {
|
||||||
@@ -1556,7 +1667,7 @@ async function streamAgentMessage(
|
|||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
const attachments = [...pendingAttachments.value]
|
const attachments = [...pendingAttachments.value]
|
||||||
if ((!text && !attachments.length) || sending.value) return
|
if ((!text && !attachments.length) || isBusy.value) return
|
||||||
|
|
||||||
streamError.value = ''
|
streamError.value = ''
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
@@ -1711,7 +1822,7 @@ function toggleVoiceRecording() {
|
|||||||
|
|
||||||
// 处理选择按钮点击,保存可读选择描述并把真实值发给 Agent。
|
// 处理选择按钮点击,保存可读选择描述并把真实值发给 Agent。
|
||||||
async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceCard, button: AgentChoiceButton) {
|
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
|
sending.value = true
|
||||||
streamError.value = ''
|
streamError.value = ''
|
||||||
@@ -1787,8 +1898,21 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
|
|||||||
// 中止当前流式回复。
|
// 中止当前流式回复。
|
||||||
function stopGeneration() {
|
function stopGeneration() {
|
||||||
userAbortRequested = true
|
userAbortRequested = true
|
||||||
pendingStreamRecovery = null
|
pendingStreamRecovery.value = null
|
||||||
clearStreamRecoveryTimer()
|
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) {
|
if (sessionId.value) {
|
||||||
fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, {
|
fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1813,7 +1937,7 @@ function startNewSession() {
|
|||||||
|
|
||||||
// 从历史列表恢复指定会话,同时把它设为当前本地会话。
|
// 从历史列表恢复指定会话,同时把它设为当前本地会话。
|
||||||
async function loadHistorySession(targetSessionId: string) {
|
async function loadHistorySession(targetSessionId: string) {
|
||||||
if (sending.value) return
|
if (isBusy.value) return
|
||||||
|
|
||||||
let historySession = historySessions.value.find(item => item.sessionId === targetSessionId)
|
let historySession = historySessions.value.find(item => item.sessionId === targetSessionId)
|
||||||
if (!historySession) return
|
if (!historySession) return
|
||||||
@@ -1837,7 +1961,7 @@ async function loadHistorySession(targetSessionId: string) {
|
|||||||
|
|
||||||
// 删除指定历史会话;若删除的是当前会话,则切换到新的空会话。
|
// 删除指定历史会话;若删除的是当前会话,则切换到新的空会话。
|
||||||
async function deleteHistorySession(targetSessionId: string) {
|
async function deleteHistorySession(targetSessionId: string) {
|
||||||
if (sending.value && targetSessionId === sessionId.value) return
|
if (isBusy.value && targetSessionId === sessionId.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
|
await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
|
||||||
@@ -1906,8 +2030,23 @@ function handleGlobalKeydown(event: KeyboardEvent) {
|
|||||||
if (event.key === 'Escape' && isOpen.value) closeDrawer()
|
if (event.key === 'Escape' && isOpen.value) closeDrawer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面从后台恢复时尝试拉取 WebAgent 后台完成后的会话快照。
|
// 页面进入后台时保存流式占位,恢复可见时尝试拉取 WebAgent 后台完成后的会话快照。
|
||||||
function handleVisibilityChange() {
|
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)
|
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1927,14 +2066,16 @@ watch(isOpen, open => {
|
|||||||
if (open) scrollToBottom()
|
if (open) scrollToBottom()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(sending, value => emit('thinking-change', value), { immediate: true })
|
watch(isBusy, value => emit('thinking-change', value), { immediate: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
restoreHistorySessions()
|
restoreHistorySessions()
|
||||||
restoreState()
|
restoreState()
|
||||||
loadServerHistorySessions()
|
loadServerHistorySessions()
|
||||||
|
if (pendingStreamRecovery.value && document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||||||
syncInputHeight()
|
syncInputHeight()
|
||||||
window.addEventListener('keydown', handleGlobalKeydown)
|
window.addEventListener('keydown', handleGlobalKeydown)
|
||||||
|
window.addEventListener('pageshow', handlePageShow)
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1948,6 +2089,7 @@ onScopeDispose(() => {
|
|||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
window.removeEventListener('pageshow', handlePageShow)
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -1980,7 +2122,7 @@ onScopeDispose(() => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
|
<div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
|
||||||
<div class="agent-assistant-status">
|
<div class="agent-assistant-status">
|
||||||
{{ sending ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
|
{{ isBusy ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2030,7 +2172,7 @@ onScopeDispose(() => {
|
|||||||
class="agent-assistant-history-item"
|
class="agent-assistant-history-item"
|
||||||
:class="{ 'is-active': isCurrentHistorySession(historySession.sessionId) }"
|
:class="{ 'is-active': isCurrentHistorySession(historySession.sessionId) }"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="sending"
|
:disabled="isBusy"
|
||||||
@click="loadHistorySession(historySession.sessionId)"
|
@click="loadHistorySession(historySession.sessionId)"
|
||||||
>
|
>
|
||||||
<span class="agent-assistant-history-item__content">
|
<span class="agent-assistant-history-item__content">
|
||||||
@@ -2044,7 +2186,7 @@ onScopeDispose(() => {
|
|||||||
</span>
|
</span>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
size="x-small"
|
size="x-small"
|
||||||
:disabled="sending"
|
:disabled="isBusy"
|
||||||
:title="t('agentAssistant.deleteHistory')"
|
:title="t('agentAssistant.deleteHistory')"
|
||||||
:aria-label="t('agentAssistant.deleteHistory')"
|
:aria-label="t('agentAssistant.deleteHistory')"
|
||||||
@click.stop="deleteHistorySession(historySession.sessionId)"
|
@click.stop="deleteHistorySession(historySession.sessionId)"
|
||||||
@@ -2065,7 +2207,7 @@ onScopeDispose(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
:disabled="sending"
|
:disabled="isBusy"
|
||||||
:title="t('agentAssistant.newChat')"
|
:title="t('agentAssistant.newChat')"
|
||||||
:aria-label="t('agentAssistant.newChat')"
|
:aria-label="t('agentAssistant.newChat')"
|
||||||
@click="startNewSession"
|
@click="startNewSession"
|
||||||
@@ -2148,7 +2290,7 @@ onScopeDispose(() => {
|
|||||||
class="agent-assistant-choice__button"
|
class="agent-assistant-choice__button"
|
||||||
size="small"
|
size="small"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
:disabled="sending || choice.status !== 'pending'"
|
:disabled="isBusy || choice.status !== 'pending'"
|
||||||
@click="handleChoiceClick(message, choice, button)"
|
@click="handleChoiceClick(message, choice, button)"
|
||||||
>
|
>
|
||||||
{{ button.label }}
|
{{ button.label }}
|
||||||
@@ -2259,7 +2401,7 @@ onScopeDispose(() => {
|
|||||||
<IconBtn
|
<IconBtn
|
||||||
class="agent-assistant-surface-btn"
|
class="agent-assistant-surface-btn"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
:disabled="sending"
|
:disabled="isBusy"
|
||||||
:title="t('agentAssistant.removeAttachment')"
|
:title="t('agentAssistant.removeAttachment')"
|
||||||
:aria-label="t('agentAssistant.removeAttachment')"
|
:aria-label="t('agentAssistant.removeAttachment')"
|
||||||
@click="removePendingAttachment(attachment.id)"
|
@click="removePendingAttachment(attachment.id)"
|
||||||
@@ -2291,12 +2433,12 @@ onScopeDispose(() => {
|
|||||||
class="agent-assistant-file-input"
|
class="agent-assistant-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
:disabled="sending"
|
:disabled="isBusy"
|
||||||
@change="handleFileSelection"
|
@change="handleFileSelection"
|
||||||
/>
|
/>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
class="agent-assistant-attach agent-assistant-surface-btn"
|
class="agent-assistant-attach agent-assistant-surface-btn"
|
||||||
:disabled="sending || recording"
|
:disabled="isBusy || recording"
|
||||||
:title="t('agentAssistant.attachFile')"
|
:title="t('agentAssistant.attachFile')"
|
||||||
:aria-label="t('agentAssistant.attachFile')"
|
:aria-label="t('agentAssistant.attachFile')"
|
||||||
@click="openFilePicker"
|
@click="openFilePicker"
|
||||||
@@ -2308,7 +2450,7 @@ onScopeDispose(() => {
|
|||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
class="agent-assistant-textarea"
|
class="agent-assistant-textarea"
|
||||||
rows="1"
|
rows="1"
|
||||||
:disabled="sending || recording"
|
:disabled="isBusy || recording"
|
||||||
:placeholder="inputPlaceholder"
|
:placeholder="inputPlaceholder"
|
||||||
@input="handleInputChange"
|
@input="handleInputChange"
|
||||||
@keydown="handleInputKeydown"
|
@keydown="handleInputKeydown"
|
||||||
@@ -2333,12 +2475,12 @@ onScopeDispose(() => {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
class="agent-assistant-send agent-assistant-surface-btn"
|
class="agent-assistant-send agent-assistant-surface-btn"
|
||||||
:disabled="!sending && !canSend"
|
:disabled="!isBusy && !canSend"
|
||||||
:title="sending ? t('agentAssistant.stop') : t('common.send')"
|
:title="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||||||
:aria-label="sending ? t('agentAssistant.stop') : t('common.send')"
|
:aria-label="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||||||
@click="sending ? stopGeneration() : sendMessage()"
|
@click="isBusy ? stopGeneration() : sendMessage()"
|
||||||
>
|
>
|
||||||
<VIcon :icon="sending ? 'mdi-stop' : 'mdi-send'" />
|
<VIcon :icon="isBusy ? 'mdi-stop' : 'mdi-send'" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造符合 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: '<button><slot /></button>' },
|
||||||
|
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||||
|
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<Uint8Array>({
|
||||||
|
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: '<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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -790,6 +790,7 @@ export default {
|
|||||||
choiceExpired: 'This choice expired. Please ask again.',
|
choiceExpired: 'This choice expired. Please ask again.',
|
||||||
error: 'Assistant response failed',
|
error: 'Assistant response failed',
|
||||||
noStream: 'This browser cannot read streaming responses',
|
noStream: 'This browser cannot read streaming responses',
|
||||||
|
recoveryFailed: 'Failed to recover the background response. Please try again later.',
|
||||||
},
|
},
|
||||||
workflow: {
|
workflow: {
|
||||||
components: 'Action Components',
|
components: 'Action Components',
|
||||||
|
|||||||
@@ -780,6 +780,7 @@ export default {
|
|||||||
choiceExpired: '该选择已失效,请重新发起选择',
|
choiceExpired: '该选择已失效,请重新发起选择',
|
||||||
error: '智能助手响应失败',
|
error: '智能助手响应失败',
|
||||||
noStream: '当前浏览器无法读取流式响应',
|
noStream: '当前浏览器无法读取流式响应',
|
||||||
|
recoveryFailed: '后台回复恢复失败,请稍后重试',
|
||||||
},
|
},
|
||||||
workflow: {
|
workflow: {
|
||||||
components: '动作组件',
|
components: '动作组件',
|
||||||
|
|||||||
@@ -780,6 +780,7 @@ export default {
|
|||||||
choiceExpired: '該選擇已失效,請重新發起選擇',
|
choiceExpired: '該選擇已失效,請重新發起選擇',
|
||||||
error: '智能助手響應失敗',
|
error: '智能助手響應失敗',
|
||||||
noStream: '目前瀏覽器無法讀取串流響應',
|
noStream: '目前瀏覽器無法讀取串流響應',
|
||||||
|
recoveryFailed: '後台回覆恢復失敗,請稍後重試',
|
||||||
},
|
},
|
||||||
workflow: {
|
workflow: {
|
||||||
components: '動作組件',
|
components: '動作組件',
|
||||||
|
|||||||
Reference in New Issue
Block a user