import { type Component, createSignal, createEffect, onMount, onCleanup, Show, For, } from 'solid-js'; import { useNavigate, A } from '@solidjs/router'; import { AlertCircle, AlertTriangle, CheckCircle, Circle, Loader2 } from 'lucide-solid'; import { useI18n } from '~/i18n'; import { synthesesApi } from '~/api/syntheses'; import { settingsApi } from '~/api/settings'; import { configApi } from '~/api/config'; import { themesApi, type ThemeResponse } from '~/api/themes'; import { isApiError, DEFAULT_SETTINGS } from '~/types'; import type { UserSettings, ProviderConfig, ProgressEvent } from '~/types'; import { createSSEConnection, type SSEConnection, type SSEStatus } from '~/utils/sse'; import { providerSupportsWebSearch } from '~/utils/providers'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; import Button from '~/components/ui/Button'; /** Metadata for a single generation pipeline step. */ interface StepInfo { key: string; label: string; } /** Ordered pipeline steps displayed as a checklist during generation. */ const STEPS: StepInfo[] = [ { key: 'sources', label: 'generate.step.sources' }, { key: 'websearch', label: 'generate.step.websearch' }, { key: 'saving', label: 'generate.step.saving' }, ]; /** * Synthesis generation page with real-time progress tracking. * * SSE state machine: * idle -> connecting -> connected (progress events) -> complete | error * * 1. User clicks "Generate": a POST creates a job and returns a `job_id`. * 2. An SSE connection is opened to the progress endpoint for that job. * 3. `progress` events drive the progress bar and step checklist. * 4. A `complete` event carries the `synthesis_id`; the page auto-redirects * after a short delay. An `error` event surfaces the message and stops. * 5. On connection loss, exponential backoff retries up to 3 times (see * {@link createSSEConnection}). */ const GenerateSynthesis: Component = () => { const { t } = useI18n(); const navigate = useNavigate(); const [settings, setSettings] = createSignal({ ...DEFAULT_SETTINGS }); const [providers, setProviders] = createSignal([]); const [themes, setThemes] = createSignal([]); const [selectedThemeId, setSelectedThemeId] = createSignal(''); const [loadingSettings, setLoadingSettings] = createSignal(true); const [generating, setGenerating] = createSignal(false); const [error, setError] = createSignal(null); const [success, setSuccess] = createSignal(false); const [sseConnection, setSSEConnection] = createSignal(null); const [jobId, setJobId] = createSignal(null); onMount(async () => { try { const [data, providerList, themeList] = await Promise.all([ settingsApi.get().catch((err) => { if (isApiError(err) && err.status === 404) return null; throw err; }), configApi.listProviders().catch(() => [] as ProviderConfig[]), themesApi.list().catch(() => [] as ThemeResponse[]), ]); if (data) setSettings(data); setProviders(providerList); setThemes(themeList); if (themeList.length > 0) { setSelectedThemeId(themeList[0].id); } } catch { // Non-404 settings error — use defaults silently } finally { setLoadingSettings(false); } }); const selectedProvider = (): ProviderConfig | undefined => { return providers().find((p) => p.provider_name === settings().ai_provider); }; const providerDisplayName = (): string => { return selectedProvider()?.display_name ?? settings().ai_provider; }; const modelDisplayName = (): string => { const provider = selectedProvider(); if (!provider) return settings().ai_model; const model = provider.models_scraping.find((m) => m.model_id === settings().ai_model); return model?.display_name ?? settings().ai_model; }; const hasWebSearch = (): boolean => { return providerSupportsWebSearch(settings().ai_provider); }; const currentStep = (): string | null => { const conn = sseConnection(); if (!conn) return null; const progress = conn.latestProgress(); return progress?.step ?? null; }; const currentPercent = (): number => { const conn = sseConnection(); if (!conn) return 0; const progress = conn.latestProgress(); return progress?.percent ?? 0; }; const currentMessage = (): string => { const conn = sseConnection(); if (!conn) return ''; const progress = conn.latestProgress(); return progress?.message ?? ''; }; const sseStatus = (): SSEStatus => { const conn = sseConnection(); if (!conn) return 'idle'; return conn.status(); }; // Collect completed steps from progress events const completedSteps = (): Set => { const conn = sseConnection(); if (!conn) return new Set(); const events = conn.events(); const completed = new Set(); const progressEvents = events .filter((e) => e.type === 'progress') .map((e) => (e as { type: 'progress'; data: ProgressEvent }).data); // Mark all steps before the current one as completed const current = currentStep(); for (const step of STEPS) { if (step.key === current) break; // If we've received any progress event for a later step, earlier ones are done const hasLaterEvent = progressEvents.some((pe) => { const stepIndex = STEPS.findIndex((s) => s.key === pe.step); const thisIndex = STEPS.findIndex((s) => s.key === step.key); return stepIndex > thisIndex; }); if (hasLaterEvent) { completed.add(step.key); } } // Also mark steps that had a 100%-equivalent high percent for (const pe of progressEvents) { const stepIndex = STEPS.findIndex((s) => s.key === pe.step); const currentStepIndex = current ? STEPS.findIndex((s) => s.key === current) : -1; if (stepIndex >= 0 && stepIndex < currentStepIndex) { completed.add(pe.step); } } return completed; }; const stepStatus = (stepKey: string): 'done' | 'in-progress' | 'pending' => { const conn = sseConnection(); if (!conn) return 'pending'; if (sseStatus() === 'complete') return 'done'; if (completedSteps().has(stepKey)) return 'done'; if (currentStep() === stepKey) return 'in-progress'; // Check if this step comes before the current step const thisIndex = STEPS.findIndex((s) => s.key === stepKey); const currentIndex = currentStep() ? STEPS.findIndex((s) => s.key === currentStep()) : -1; if (thisIndex < currentIndex) return 'done'; return 'pending'; }; // Auto-redirect on completion createEffect(() => { const conn = sseConnection(); if (!conn) return; const synthId = conn.completedSynthesisId(); if (synthId) { setSuccess(true); const navTimer = setTimeout(() => { navigate(`/synthesis/${synthId}`); }, 2000); onCleanup(() => clearTimeout(navTimer)); } }); // Handle SSE errors createEffect(() => { const conn = sseConnection(); if (!conn) return; const errMsg = conn.errorMessage(); if (errMsg) { setError(errMsg); setGenerating(false); } }); const handleGenerate = async () => { if (generating()) return; setGenerating(true); setError(null); setSuccess(false); try { const response = await synthesesApi.generate(selectedThemeId()); setJobId(response.job_id); const url = synthesesApi.progressUrl(response.job_id); const conn = createSSEConnection(url); setSSEConnection(conn); } catch (err) { setGenerating(false); if (isApiError(err)) { setError(err.message); } else { setError(t('generate.error')); } } }; const handleStop = async () => { const id = jobId(); if (!id) return; try { await synthesesApi.stop(id); } catch { // ignore errors — the pipeline will stop on its own } }; const handleRetry = () => { // Close existing connection const conn = sseConnection(); if (conn) { conn.close(); } setSSEConnection(null); setJobId(null); setError(null); setSuccess(false); setGenerating(false); }; const isInProgress = () => generating() && !success() && !error(); return ( }>

{t('generate.title')}

{t('generate.description')}

{t('generate.provider')}{' '} {providerDisplayName()} {' · '} {t('generate.model')}{' '} {modelDisplayName()}

{t('generate.note')}

{/* Theme selection */} 0} fallback={

{t('generate.noThemes')}{' '} {t('generate.createThemeLink')}

} >
{/* No web search warning */}

{t('generate.noWebSearchWarning')}

{/* Error display */}

{error()}

{/* Success display */}

{t('generate.complete')}

{/* Progress display */}
{/* Progress bar */}
{currentMessage()} {currentPercent()}%
{/* Step checklist */}
{(step) => { const status = () => stepStatus(step.key); return (
{t(step.label as Parameters[0])}
); }}
{/* Can leave note */}

{t('generate.canLeave')}

{/* Stop generation button */}
{/* Action buttons */}
{t('generate.retry')} } >
); }; export default GenerateSynthesis;