mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
fix: recover web agent stream after background
This commit is contained in:
@@ -76,6 +76,7 @@ interface AgentSessionHistoryItem {
|
|||||||
preview?: string
|
preview?: string
|
||||||
channel?: string
|
channel?: string
|
||||||
source?: string
|
source?: string
|
||||||
|
isProcessing?: boolean
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
messages: AgentChatMessage[]
|
messages: AgentChatMessage[]
|
||||||
@@ -143,6 +144,7 @@ interface AgentServerSession {
|
|||||||
source?: string
|
source?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
|
is_processing?: boolean
|
||||||
messages?: AgentChatMessage[]
|
messages?: AgentChatMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +207,15 @@ let recordingChunks: BlobPart[] = []
|
|||||||
let messageScrollFrame: number | null = null
|
let messageScrollFrame: number | null = null
|
||||||
let pendingMessageScrollToBottom = false
|
let pendingMessageScrollToBottom = false
|
||||||
let streamPersistTimer: number | null = null
|
let streamPersistTimer: number | null = null
|
||||||
|
let userAbortRequested = false
|
||||||
|
let streamRecoveryTimer: number | null = null
|
||||||
|
let pendingStreamRecovery:
|
||||||
|
| {
|
||||||
|
sessionId: string
|
||||||
|
startedAt: number
|
||||||
|
attempts: number
|
||||||
|
}
|
||||||
|
| null = null
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: true,
|
html: true,
|
||||||
@@ -549,6 +560,7 @@ function normalizeHistorySessions(value: unknown) {
|
|||||||
preview: typeof item?.preview === 'string' ? item.preview : undefined,
|
preview: typeof item?.preview === 'string' ? item.preview : undefined,
|
||||||
channel: typeof item?.channel === 'string' ? item.channel : undefined,
|
channel: typeof item?.channel === 'string' ? item.channel : undefined,
|
||||||
source: typeof item?.source === 'string' ? item.source : undefined,
|
source: typeof item?.source === 'string' ? item.source : undefined,
|
||||||
|
isProcessing: Boolean(item?.isProcessing),
|
||||||
createdAt: Number(item?.createdAt) || firstMessageTime,
|
createdAt: Number(item?.createdAt) || firstMessageTime,
|
||||||
updatedAt: Number(item?.updatedAt) || lastMessageTime,
|
updatedAt: Number(item?.updatedAt) || lastMessageTime,
|
||||||
messages,
|
messages,
|
||||||
@@ -577,6 +589,7 @@ function normalizeServerSession(item: AgentServerSession, withMessages = false):
|
|||||||
preview: item.preview,
|
preview: item.preview,
|
||||||
channel: item.channel,
|
channel: item.channel,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
|
isProcessing: Boolean(item.is_processing),
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
messages: withMessages ? messages : [],
|
messages: withMessages ? messages : [],
|
||||||
@@ -756,6 +769,62 @@ async function loadServerHistorySession(targetSessionId: string) {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理等待中的 WebAgent 断流恢复轮询。
|
||||||
|
function clearStreamRecoveryTimer() {
|
||||||
|
if (streamRecoveryTimer === null) return
|
||||||
|
|
||||||
|
window.clearTimeout(streamRecoveryTimer)
|
||||||
|
streamRecoveryTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从服务端拉取当前会话展示快照,用于移动端后台断开 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 }
|
||||||
|
if (session.updatedAt < startedAt - 1000) return { restored: false, pending: Boolean(session.isProcessing) }
|
||||||
|
if (!session.messages.length) return { restored: false, pending: Boolean(session.isProcessing) }
|
||||||
|
|
||||||
|
messages.value = normalizeStoredMessages(session.messages)
|
||||||
|
pendingStreamRecovery = null
|
||||||
|
persistState({ syncHistory: false })
|
||||||
|
scrollToBottom()
|
||||||
|
return { restored: true, pending: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebAgent SSE 断流后,后台任务仍会完成并保存快照;前端回到前台后轮询拉取。
|
||||||
|
function scheduleStreamRecovery(delay = 1200) {
|
||||||
|
if (!pendingStreamRecovery || typeof window === 'undefined') return
|
||||||
|
|
||||||
|
clearStreamRecoveryTimer()
|
||||||
|
streamRecoveryTimer = window.setTimeout(async () => {
|
||||||
|
streamRecoveryTimer = null
|
||||||
|
if (!pendingStreamRecovery) return
|
||||||
|
if (document.visibilityState === 'hidden') return
|
||||||
|
|
||||||
|
const recovery = pendingStreamRecovery
|
||||||
|
try {
|
||||||
|
const result = await restoreCurrentSessionFromServer(recovery.sessionId, recovery.startedAt)
|
||||||
|
if (result.restored) return
|
||||||
|
if (result.pending) {
|
||||||
|
scheduleStreamRecovery(Math.min(8000, 1200 + recovery.attempts * 900))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 会话快照可能还未写入,继续按退避间隔等待。
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingStreamRecovery || pendingStreamRecovery.sessionId !== recovery.sessionId) return
|
||||||
|
pendingStreamRecovery.attempts += 1
|
||||||
|
if (pendingStreamRecovery.attempts > 8) {
|
||||||
|
pendingStreamRecovery = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.attempts * 900))
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
// 恢复当前会话状态,优先使用本地状态,缺失时使用最近历史。
|
// 恢复当前会话状态,优先使用本地状态,缺失时使用最近历史。
|
||||||
function restoreState() {
|
function restoreState() {
|
||||||
try {
|
try {
|
||||||
@@ -1185,6 +1254,22 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移动端浏览器退到后台时,SSE/fetch 可能以 TypeError: Load failed 等形式被动断开。
|
||||||
|
function isRecoverableStreamDisconnect(error: any) {
|
||||||
|
if (userAbortRequested) return false
|
||||||
|
|
||||||
|
const message = String(error?.message || error || '').toLowerCase()
|
||||||
|
if (document.visibilityState === 'hidden') return true
|
||||||
|
|
||||||
|
return (
|
||||||
|
error?.name === 'AbortError' ||
|
||||||
|
message.includes('load failed') ||
|
||||||
|
message.includes('failed to fetch') ||
|
||||||
|
message.includes('networkerror') ||
|
||||||
|
message.includes('network error')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 解析附件地址,支持相对 API 路径。
|
// 解析附件地址,支持相对 API 路径。
|
||||||
function resolveAttachmentUrl(url?: string) {
|
function resolveAttachmentUrl(url?: string) {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
@@ -1359,7 +1444,10 @@ async function streamAgentMessage(
|
|||||||
const assistantMessage = addMessage('assistant', '', 'streaming')
|
const assistantMessage = addMessage('assistant', '', 'streaming')
|
||||||
|
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
|
userAbortRequested = false
|
||||||
|
const streamStartedAt = Date.now()
|
||||||
let shouldFollowBottomAfterStream = true
|
let shouldFollowBottomAfterStream = true
|
||||||
|
let shouldSaveClientSnapshot = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
||||||
@@ -1401,26 +1489,43 @@ async function streamAgentMessage(
|
|||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.name === 'AbortError') {
|
if (error?.name === 'AbortError' && userAbortRequested) {
|
||||||
assistantMessage.status = 'done'
|
assistantMessage.status = 'done'
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecoverableStreamDisconnect(error)) {
|
||||||
|
shouldSaveClientSnapshot = false
|
||||||
|
pendingStreamRecovery = {
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
startedAt: streamStartedAt,
|
||||||
|
attempts: 0,
|
||||||
|
}
|
||||||
|
assistantMessage.status = 'done'
|
||||||
|
markToolsDone(assistantMessage)
|
||||||
|
refreshMessageList()
|
||||||
|
if (document.visibilityState === 'visible') scheduleStreamRecovery(1200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
assistantMessage.status = 'error'
|
assistantMessage.status = 'error'
|
||||||
assistantMessage.content = error?.message || t('agentAssistant.error')
|
assistantMessage.content = error?.message || t('agentAssistant.error')
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
} finally {
|
} finally {
|
||||||
abortController = null
|
abortController = null
|
||||||
|
userAbortRequested = false
|
||||||
clearStreamPersistTimer()
|
clearStreamPersistTimer()
|
||||||
persistState()
|
persistState()
|
||||||
try {
|
if (shouldSaveClientSnapshot) {
|
||||||
await saveCurrentSessionToServer()
|
try {
|
||||||
await loadServerHistorySessions()
|
await saveCurrentSessionToServer()
|
||||||
} catch (error) {
|
await loadServerHistorySessions()
|
||||||
// 服务端历史保存失败时保留本地兜底历史,不影响当前会话继续交互。
|
} catch (error) {
|
||||||
|
// 服务端历史保存失败时保留本地兜底历史,不影响当前会话继续交互。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (shouldFollowBottomAfterStream) scrollToBottom()
|
if (shouldFollowBottomAfterStream) scrollToBottom()
|
||||||
}
|
}
|
||||||
@@ -1661,6 +1766,16 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
|
|||||||
|
|
||||||
// 中止当前流式回复。
|
// 中止当前流式回复。
|
||||||
function stopGeneration() {
|
function stopGeneration() {
|
||||||
|
userAbortRequested = true
|
||||||
|
pendingStreamRecovery = null
|
||||||
|
clearStreamRecoveryTimer()
|
||||||
|
if (sessionId.value) {
|
||||||
|
fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).catch(() => {
|
||||||
|
// 本地中止优先,停止接口失败不阻塞用户操作。
|
||||||
|
})
|
||||||
|
}
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1771,6 +1886,11 @@ function handleGlobalKeydown(event: KeyboardEvent) {
|
|||||||
if (event.key === 'Escape' && isOpen.value) closeDrawer()
|
if (event.key === 'Escape' && isOpen.value) closeDrawer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 页面从后台恢复时尝试拉取 WebAgent 后台完成后的会话快照。
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||||||
|
}
|
||||||
|
|
||||||
// 处理输入框回车发送。
|
// 处理输入框回车发送。
|
||||||
function handleInputKeydown(event: KeyboardEvent) {
|
function handleInputKeydown(event: KeyboardEvent) {
|
||||||
if (event.key !== 'Enter' || event.shiftKey) return
|
if (event.key !== 'Enter' || event.shiftKey) return
|
||||||
@@ -1795,6 +1915,7 @@ onMounted(() => {
|
|||||||
loadServerHistorySessions()
|
loadServerHistorySessions()
|
||||||
syncInputHeight()
|
syncInputHeight()
|
||||||
window.addEventListener('keydown', handleGlobalKeydown)
|
window.addEventListener('keydown', handleGlobalKeydown)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
onScopeDispose(clearAgentAssistantOpenState)
|
onScopeDispose(clearAgentAssistantOpenState)
|
||||||
@@ -1802,10 +1923,12 @@ onScopeDispose(clearPendingAttachments)
|
|||||||
onScopeDispose(cancelVoiceRecording)
|
onScopeDispose(cancelVoiceRecording)
|
||||||
onScopeDispose(clearMessageScrollFrame)
|
onScopeDispose(clearMessageScrollFrame)
|
||||||
onScopeDispose(clearStreamPersistTimer)
|
onScopeDispose(clearStreamPersistTimer)
|
||||||
|
onScopeDispose(clearStreamRecoveryTimer)
|
||||||
onScopeDispose(() => {
|
onScopeDispose(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user