Quellcode durchsuchen

修复对话切换和刷新中断任务

LSamien vor 2 Monaten
Ursprung
Commit
fee4242688
3 geänderte Dateien mit 83 neuen und 1 gelöschten Zeilen
  1. 4 0
      src/client/App.vue
  2. 79 0
      src/client/stores/chat.ts
  3. 0 1
      src/client/views/ChatView.vue

+ 4 - 0
src/client/App.vue

@@ -31,6 +31,10 @@ watch(() => theme.resolvedTheme, value => {
 
 watch(pageTitle, value => { document.title = value }, { immediate: true })
 
+watch(() => auth.isAuthenticated, authenticated => {
+  if (authenticated) void chat.connect().catch(() => undefined)
+})
+
 onMounted(() => auth.bootstrap())
 </script>
 

+ 79 - 0
src/client/stores/chat.ts

@@ -21,6 +21,42 @@ import { useAuthStore } from './auth'
 
 interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number }
 const historyCache = new ScopedCache<CachedHistory>('chat-history-v1')
+const INFLIGHT_SESSION_STORAGE_PREFIX = 'hermes-yaoyao:inflight-chat-sessions:'
+
+interface InflightSessionMarker {
+  profile: string
+  sessionId: string
+}
+
+function sessionStorageForInFlightSessions(): Storage | undefined {
+  if (typeof window === 'undefined') return undefined
+  try { return window.sessionStorage } catch { return undefined }
+}
+
+function readInflightSessionMarkers(accountId: string): InflightSessionMarker[] {
+  const storage = sessionStorageForInFlightSessions()
+  if (!storage) return []
+  try {
+    const value: unknown = JSON.parse(storage.getItem(`${INFLIGHT_SESSION_STORAGE_PREFIX}${accountId}`) ?? '[]')
+    if (!Array.isArray(value)) return []
+    return value.flatMap(item => {
+      const marker = record(item)
+      const profile = string(marker.profile)
+      const sessionId = string(marker.sessionId)
+      return profile && sessionId ? [{ profile, sessionId }] : []
+    })
+  } catch { return [] }
+}
+
+function writeInflightSessionMarkers(accountId: string, markers: InflightSessionMarker[]): void {
+  const storage = sessionStorageForInFlightSessions()
+  if (!storage) return
+  try {
+    const key = `${INFLIGHT_SESSION_STORAGE_PREFIX}${accountId}`
+    if (markers.length) storage.setItem(key, JSON.stringify(markers))
+    else storage.removeItem(key)
+  } catch { /* Storage can be unavailable in privacy-restricted browsers. */ }
+}
 
 function emptyRoute(profile: string, sessionId: string): ChatRouteState {
   return {
@@ -72,6 +108,7 @@ export const useChatStore = defineStore('chat', () => {
   let modelLoadGeneration = 0
   let unreadLoadGeneration = 0
   let reconnectResumePromise: Promise<unknown> | undefined
+  let persistedRecoveryPromise: Promise<void> | undefined
   let hasConnectedOnce = false
   const runtimePromises = new Map<string, Promise<ChatRouteState>>()
 
@@ -123,6 +160,42 @@ export const useChatStore = defineStore('chat', () => {
     }, delay)
   }
 
+  function accountId(): string { return auth.user?.id ?? 'anonymous' }
+
+  function updateInflightMarker(state: ChatRouteState): void {
+    const markers = readInflightSessionMarkers(accountId())
+    const matches = (marker: InflightSessionMarker) => marker.profile === state.route.profile
+      && marker.sessionId === state.route.sessionId
+    const shouldKeep = state.isStreaming || state.isQueued || Boolean(state.pendingApproval) || Boolean(state.pendingClarification)
+    const next = shouldKeep
+      ? [...markers.filter(marker => !matches(marker)), { profile: state.route.profile, sessionId: state.route.sessionId }]
+      : markers.filter(marker => !matches(marker))
+    writeInflightSessionMarkers(accountId(), next)
+  }
+
+  function clearInflightMarker(profile: string, sessionId: string): void {
+    const markers = readInflightSessionMarkers(accountId())
+    writeInflightSessionMarkers(accountId(), markers.filter(marker => marker.profile !== profile || marker.sessionId !== sessionId))
+  }
+
+  function recoverPersistedInflightSessions(): Promise<void> | undefined {
+    if (!auth.isAuthenticated || persistedRecoveryPromise) return persistedRecoveryPromise
+    const markers = readInflightSessionMarkers(accountId())
+    if (!markers.length) return undefined
+    const operation = Promise.all(markers.map(async marker => {
+      const state = ensureRoute(marker.profile, marker.sessionId)
+      try {
+        const resumed = await ensureRuntime(state)
+        updateInflightMarker(resumed)
+      } catch {
+        // Keep the marker. A transient reconnect failure must not turn into an
+        // accidental interruption before Hermes's disconnect grace expires.
+      }
+    })).then(() => undefined)
+    persistedRecoveryPromise = operation.finally(() => { persistedRecoveryPromise = undefined })
+    return persistedRecoveryPromise
+  }
+
   socket.onState((state, reason) => {
     connectionState.value = state
     if (state === 'ready') {
@@ -135,6 +208,7 @@ export const useChatStore = defineStore('chat', () => {
           .catch(cause => { active.error = errorMessage(cause) })
           .finally(() => { reconnectResumePromise = undefined })
       }
+      void recoverPersistedInflightSessions()
     }
     if (state === 'failed') {
       runtimeRoutes.clear()
@@ -206,6 +280,7 @@ export const useChatStore = defineStore('chat', () => {
       ...event, session_id: state.route.sessionId, profile: state.route.profile, payload: attributedPayload,
     })
     routes[key] = updated
+    updateInflightMarker(updated)
     if (['message.complete', 'run.completed'].includes(event.type)) {
       void refreshContextUsage(updated).catch(() => undefined)
     }
@@ -525,6 +600,8 @@ export const useChatStore = defineStore('chat', () => {
       choices: Array.isArray(clarification.choices) ? clarification.choices.map(String) : undefined,
       payload: clarification as Record<string, JsonValue>,
     }
+    if (storedId !== initialState.route.sessionId) clearInflightMarker(initialState.route.profile, initialState.route.sessionId)
+    updateInflightMarker(migrated)
     return migrated
   }
 
@@ -636,6 +713,7 @@ export const useChatStore = defineStore('chat', () => {
           state.isStreaming = true
         }
       }
+      updateInflightMarker(state)
       // The optimistic row remains local, but the sidebar is refreshed only
       // after a 9119 receipt and keeps the order returned by the server.
       void loadSessions(state.route.profile).catch(() => undefined)
@@ -655,6 +733,7 @@ export const useChatStore = defineStore('chat', () => {
     await socket.request('session.interrupt', { session_id: current.runtimeSessionId! }, 15_000)
     current.isStreaming = false
     current.isQueued = false
+    clearInflightMarker(current.route.profile, current.route.sessionId)
   }
 
   async function respondToApproval(requestId: string, approved: boolean | 'once' | 'session' | 'always' | 'deny'): Promise<void> {

+ 0 - 1
src/client/views/ChatView.vue

@@ -341,7 +341,6 @@ onMounted(async () => {
 onBeforeUnmount(() => {
   document.removeEventListener('pointerdown', handleSessionActionPointer)
   document.removeEventListener('keydown', handleSessionActionKey)
-  chat.disconnect()
 })
 
 watch(() => auth.activeProfile?.name, async (profile, previous) => {