mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 09:05:28 +08:00
fix(agent): recover PWA background replies
This commit is contained in:
@@ -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<AgentSlashCommand[]>([])
|
||||
const slashCommandsLoading = ref(false)
|
||||
const slashCommandsLoaded = ref(false)
|
||||
const pendingStreamRecovery = ref<AgentPendingStreamRecovery | null>(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<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() {
|
||||
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<AgentStreamReadResult> {
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
@@ -1980,7 +2122,7 @@ onScopeDispose(() => {
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
|
||||
<div class="agent-assistant-status">
|
||||
{{ sending ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
|
||||
{{ isBusy ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)"
|
||||
>
|
||||
<span class="agent-assistant-history-item__content">
|
||||
@@ -2044,7 +2186,7 @@ onScopeDispose(() => {
|
||||
</span>
|
||||
<IconBtn
|
||||
size="x-small"
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
:title="t('agentAssistant.deleteHistory')"
|
||||
:aria-label="t('agentAssistant.deleteHistory')"
|
||||
@click.stop="deleteHistorySession(historySession.sessionId)"
|
||||
@@ -2065,7 +2207,7 @@ onScopeDispose(() => {
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<IconBtn
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
:title="t('agentAssistant.newChat')"
|
||||
:aria-label="t('agentAssistant.newChat')"
|
||||
@click="startNewSession"
|
||||
@@ -2148,7 +2290,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(() => {
|
||||
<IconBtn
|
||||
class="agent-assistant-surface-btn"
|
||||
size="x-small"
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
:title="t('agentAssistant.removeAttachment')"
|
||||
:aria-label="t('agentAssistant.removeAttachment')"
|
||||
@click="removePendingAttachment(attachment.id)"
|
||||
@@ -2291,12 +2433,12 @@ onScopeDispose(() => {
|
||||
class="agent-assistant-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
@change="handleFileSelection"
|
||||
/>
|
||||
<IconBtn
|
||||
class="agent-assistant-attach agent-assistant-surface-btn"
|
||||
:disabled="sending || recording"
|
||||
:disabled="isBusy || recording"
|
||||
:title="t('agentAssistant.attachFile')"
|
||||
:aria-label="t('agentAssistant.attachFile')"
|
||||
@click="openFilePicker"
|
||||
@@ -2308,7 +2450,7 @@ onScopeDispose(() => {
|
||||
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(() => {
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
class="agent-assistant-send agent-assistant-surface-btn"
|
||||
:disabled="!sending && !canSend"
|
||||
:title="sending ? t('agentAssistant.stop') : t('common.send')"
|
||||
:aria-label="sending ? t('agentAssistant.stop') : t('common.send')"
|
||||
@click="sending ? stopGeneration() : sendMessage()"
|
||||
:disabled="!isBusy && !canSend"
|
||||
:title="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||||
:aria-label="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||||
@click="isBusy ? stopGeneration() : sendMessage()"
|
||||
>
|
||||
<VIcon :icon="sending ? 'mdi-stop' : 'mdi-send'" />
|
||||
<VIcon :icon="isBusy ? 'mdi-stop' : 'mdi-send'" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
240
src/components/agent/__tests__/AgentAssistantPanel.spec.ts
Normal file
240
src/components/agent/__tests__/AgentAssistantPanel.spec.ts
Normal file
@@ -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.',
|
||||
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',
|
||||
|
||||
@@ -780,6 +780,7 @@ export default {
|
||||
choiceExpired: '该选择已失效,请重新发起选择',
|
||||
error: '智能助手响应失败',
|
||||
noStream: '当前浏览器无法读取流式响应',
|
||||
recoveryFailed: '后台回复恢复失败,请稍后重试',
|
||||
},
|
||||
workflow: {
|
||||
components: '动作组件',
|
||||
|
||||
@@ -780,6 +780,7 @@ export default {
|
||||
choiceExpired: '該選擇已失效,請重新發起選擇',
|
||||
error: '智能助手響應失敗',
|
||||
noStream: '目前瀏覽器無法讀取串流響應',
|
||||
recoveryFailed: '後台回覆恢復失敗,請稍後重試',
|
||||
},
|
||||
workflow: {
|
||||
components: '動作組件',
|
||||
|
||||
Reference in New Issue
Block a user