Spaces:
Runtime error
Runtime error
| import { useQueryClient } from '@tanstack/react-query'; | |
| import { useEffect, useRef } from 'react'; | |
| import { useToast } from '@/components/ui/use-toast'; | |
| import { apiClient } from '@/lib/api/client'; | |
| import { useGenerationSettings } from '@/lib/hooks/useSettings'; | |
| import { useGenerationStore } from '@/stores/generationStore'; | |
| import { usePlayerStore } from '@/stores/playerStore'; | |
| interface GenerationStatusEvent { | |
| id: string; | |
| status: 'loading_model' | 'generating' | 'completed' | 'failed' | 'not_found'; | |
| duration?: number; | |
| error?: string; | |
| source?: string; | |
| } | |
| // Agent-initiated generations are played by the floating pill, not the | |
| // main-window AudioPlayer. Skip autoplay here to avoid double-playback. | |
| const AGENT_SOURCES = new Set(['mcp', 'rest']); | |
| /** | |
| * Subscribes to SSE for all pending generations. When a generation completes, | |
| * invalidates the history query, removes it from pending, and auto-plays | |
| * if the player is idle. | |
| */ | |
| export function useGenerationProgress() { | |
| const queryClient = useQueryClient(); | |
| const { toast } = useToast(); | |
| const pendingIds = useGenerationStore((s) => s.pendingGenerationIds); | |
| const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration); | |
| const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd); | |
| const isPlaying = usePlayerStore((s) => s.isPlaying); | |
| const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay); | |
| const { settings: genSettings } = useGenerationSettings(); | |
| const autoplayOnGenerate = genSettings?.autoplay_on_generate ?? true; | |
| // Keep refs to avoid stale closures in EventSource handlers | |
| const isPlayingRef = useRef(isPlaying); | |
| const autoplayRef = useRef(autoplayOnGenerate); | |
| isPlayingRef.current = isPlaying; | |
| autoplayRef.current = autoplayOnGenerate; | |
| // Track active EventSource instances | |
| const eventSourcesRef = useRef<Map<string, EventSource>>(new Map()); | |
| // Unmount-only cleanup — close all SSE connections when the hook is torn down | |
| useEffect(() => { | |
| const sources = eventSourcesRef.current; | |
| return () => { | |
| for (const source of sources.values()) { | |
| source.close(); | |
| } | |
| sources.clear(); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| const currentSources = eventSourcesRef.current; | |
| // Close SSE connections for IDs no longer pending | |
| for (const [id, source] of currentSources.entries()) { | |
| if (!pendingIds.has(id)) { | |
| source.close(); | |
| currentSources.delete(id); | |
| } | |
| } | |
| // Open SSE connections for new pending IDs | |
| for (const id of pendingIds) { | |
| if (currentSources.has(id)) continue; | |
| const url = apiClient.getGenerationStatusUrl(id); | |
| const source = new EventSource(url); | |
| source.onmessage = (event) => { | |
| try { | |
| const data: GenerationStatusEvent = JSON.parse(event.data); | |
| if (data.status === 'completed') { | |
| source.close(); | |
| currentSources.delete(id); | |
| removePendingGeneration(id); | |
| // Refetch history to pick up the completed generation | |
| queryClient.refetchQueries({ queryKey: ['history'] }); | |
| // If this generation was queued for a story, add it now | |
| const storyId = removePendingStoryAdd(id); | |
| if (storyId) { | |
| apiClient | |
| .addStoryItem(storyId, { generation_id: id }) | |
| .then(() => { | |
| queryClient.invalidateQueries({ queryKey: ['stories'] }); | |
| queryClient.invalidateQueries({ queryKey: ['stories', storyId] }); | |
| toast({ | |
| title: 'Added to story', | |
| description: data.duration | |
| ? `Audio generated (${data.duration.toFixed(2)}s) and added to story` | |
| : 'Audio generated and added to story', | |
| }); | |
| }) | |
| .catch(() => { | |
| toast({ | |
| title: 'Generation complete', | |
| description: 'Audio generated but failed to add to story', | |
| variant: 'destructive', | |
| }); | |
| }); | |
| } else { | |
| // toast({ | |
| // title: 'Generation complete!', | |
| // description: data.duration | |
| // ? `Audio generated (${data.duration.toFixed(2)}s)` | |
| // : 'Audio generated', | |
| // }); | |
| } | |
| // Auto-play if enabled and nothing is currently playing. | |
| // Skip agent-initiated sources — the floating pill window | |
| // plays those itself. | |
| const isAgentSpeak = data.source ? AGENT_SOURCES.has(data.source) : false; | |
| if (autoplayRef.current && !isPlayingRef.current && !isAgentSpeak) { | |
| const genAudioUrl = apiClient.getAudioUrl(id); | |
| setAudioWithAutoPlay(genAudioUrl, id, '', ''); | |
| } | |
| } else if (data.status === 'failed' || data.status === 'not_found') { | |
| source.close(); | |
| currentSources.delete(id); | |
| removePendingGeneration(id); | |
| removePendingStoryAdd(id); | |
| queryClient.refetchQueries({ queryKey: ['history'] }); | |
| toast({ | |
| title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed', | |
| description: data.error || 'An error occurred during generation', | |
| variant: 'destructive', | |
| }); | |
| } | |
| } catch { | |
| // Ignore parse errors from heartbeats etc | |
| } | |
| }; | |
| source.onerror = () => { | |
| // SSE connection dropped — clean up and refresh history so any | |
| // completed/failed generation still appears in the list | |
| source.close(); | |
| currentSources.delete(id); | |
| removePendingGeneration(id); | |
| queryClient.refetchQueries({ queryKey: ['history'] }); | |
| }; | |
| currentSources.set(id, source); | |
| } | |
| }, [ | |
| pendingIds, | |
| removePendingGeneration, | |
| removePendingStoryAdd, | |
| queryClient, | |
| toast, | |
| setAudioWithAutoPlay, | |
| ]); | |
| } | |