Spaces:
Running on Zero
Running on Zero
| <script lang="ts"> | |
| import { browser, dev } from "$app/environment"; | |
| import { page } from "$app/state"; | |
| import { base } from "$app/paths"; | |
| import { safeInvalidate } from "$lib/utils/safeInvalidate"; | |
| import { | |
| backgroundGenerationEntries, | |
| removeBackgroundGeneration, | |
| } from "$lib/stores/backgroundGenerations"; | |
| import { UrlDependency } from "$lib/types/UrlDependency"; | |
| import { useConversationsStore } from "$lib/stores/conversations.svelte"; | |
| /** | |
| * Maximum time a background generation entry is tracked before we give up | |
| * and invalidate anyway. Mirrors the old 1Hz poller's MAX_POLL_DURATION_MS. | |
| */ | |
| const MAX_TRACK_DURATION_MS = 3 * 60_000; | |
| /** | |
| * How long to wait before reconnecting the SSE stream after it closes | |
| * (server-enforced 5-min lifetime causes a normal close; use a brief delay | |
| * so we do not hammer the server if there is a transient error). | |
| */ | |
| const RECONNECT_DELAY_MS = 1_000; | |
| const convsStore = useConversationsStore(); | |
| $effect.root(() => { | |
| if (!browser) return; | |
| let destroyed = false; | |
| let eventSource: EventSource | null = null; | |
| let reconnectTimer: ReturnType<typeof setTimeout> | null = null; | |
| const log = (...args: unknown[]) => { | |
| if (dev) console.log("[BackgroundGenerationPoller]", ...args); | |
| }; | |
| /** Most-recent updatedAt seen across all events; used as reconnect cursor. */ | |
| let latestCursor = new Date(0).toISOString(); | |
| function openStream() { | |
| if (destroyed || backgroundGenerationEntries.length === 0) return; | |
| if (eventSource) return; // already open | |
| const url = new URL(`${base}/api/v2/conversations/updates`, window.location.href); | |
| url.searchParams.set("cursor", latestCursor); | |
| log("opening SSE stream, cursor:", latestCursor); | |
| eventSource = new EventSource(url.toString()); | |
| eventSource.addEventListener("update", (raw) => { | |
| if (destroyed) return; | |
| let event: { | |
| id: string; | |
| title: string; | |
| updatedAt: string; | |
| isTerminal: boolean; | |
| }; | |
| try { | |
| event = JSON.parse(raw.data) as typeof event; | |
| } catch (err) { | |
| console.error("[BackgroundGenerationPoller] bad SSE payload", err); | |
| return; | |
| } | |
| log("update", event); | |
| // Advance cursor | |
| if (event.updatedAt > latestCursor) { | |
| latestCursor = event.updatedAt; | |
| } | |
| // Update sidebar title / updatedAt for this conversation | |
| convsStore.update(event.id, { | |
| title: event.title, | |
| updatedAt: new Date(event.updatedAt), | |
| }); | |
| const isTracked = backgroundGenerationEntries.some((e) => e.id === event.id); | |
| if (!isTracked) return; | |
| if (event.isTerminal) { | |
| removeBackgroundGeneration(event.id); | |
| log("complete", event.id); | |
| // Refresh the sidebar via the client-owned store, then invalidate the | |
| // conversation detail if the user is currently viewing it. | |
| void convsStore.refresh().then(() => { | |
| // Only invalidate the conversation detail when it is currently open | |
| if (page.params.id === event.id) { | |
| return safeInvalidate(UrlDependency.Conversation); | |
| } | |
| }); | |
| } else { | |
| // Non-terminal update: if the affected conversation is open, refresh it | |
| if (page.params.id === event.id) { | |
| void safeInvalidate(UrlDependency.Conversation); | |
| } | |
| } | |
| }); | |
| eventSource.onerror = () => { | |
| if (destroyed) return; | |
| log("SSE error / close — scheduling reconnect"); | |
| closeStream(); | |
| // Reconnect only if there are still entries to track | |
| if (backgroundGenerationEntries.length > 0) { | |
| reconnectTimer = setTimeout(() => { | |
| reconnectTimer = null; | |
| openStream(); | |
| }, RECONNECT_DELAY_MS); | |
| } | |
| }; | |
| } | |
| function closeStream() { | |
| if (eventSource) { | |
| eventSource.close(); | |
| eventSource = null; | |
| log("SSE stream closed"); | |
| } | |
| } | |
| // Timeout guard: entries that have been tracked too long are evicted, | |
| // mirroring the old poller's MAX_POLL_DURATION_MS behaviour. | |
| function evictTimedOutEntries() { | |
| const now = Date.now(); | |
| const timedOut = backgroundGenerationEntries.filter( | |
| (e) => now - e.startedAt > MAX_TRACK_DURATION_MS | |
| ); | |
| for (const entry of timedOut) { | |
| removeBackgroundGeneration(entry.id); | |
| log("timeout evict", entry.id); | |
| void convsStore.refresh().then(() => { | |
| if (page.params.id === entry.id) { | |
| return safeInvalidate(UrlDependency.Conversation); | |
| } | |
| }); | |
| } | |
| } | |
| // Evict timed-out entries on an interval even when the SSE stream is quiet. | |
| const evictTimer = setInterval(evictTimedOutEntries, 10_000); | |
| $effect(() => { | |
| // Reactive dep: re-evaluate whenever the entries array changes. | |
| const hasEntries = backgroundGenerationEntries.length > 0; | |
| if (destroyed) return; | |
| if (hasEntries) { | |
| openStream(); | |
| } else { | |
| // No active generations — close the stream to conserve connections. | |
| if (reconnectTimer !== null) { | |
| clearTimeout(reconnectTimer); | |
| reconnectTimer = null; | |
| } | |
| closeStream(); | |
| } | |
| }); | |
| return () => { | |
| destroyed = true; | |
| clearInterval(evictTimer); | |
| if (reconnectTimer !== null) clearTimeout(reconnectTimer); | |
| closeStream(); | |
| }; | |
| }); | |
| </script> | |