You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

461 lines
16 KiB
TypeScript

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<UserSettings>({ ...DEFAULT_SETTINGS });
const [providers, setProviders] = createSignal<ProviderConfig[]>([]);
const [themes, setThemes] = createSignal<ThemeResponse[]>([]);
const [selectedThemeId, setSelectedThemeId] = createSignal<string>('');
const [loadingSettings, setLoadingSettings] = createSignal(true);
const [generating, setGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [success, setSuccess] = createSignal(false);
const [sseConnection, setSSEConnection] = createSignal<SSEConnection | null>(null);
const [jobId, setJobId] = createSignal<string | null>(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<string> => {
const conn = sseConnection();
if (!conn) return new Set();
const events = conn.events();
const completed = new Set<string>();
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 (
<Show when={!loadingSettings()} fallback={<LoadingSpinner />}>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{t('generate.title')}
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p class="text-gray-600">
{t('generate.description')}
</p>
<Show when={settings().ai_provider}>
<p class="mt-2 text-sm text-gray-500">
<span class="font-medium text-gray-600">{t('generate.provider')}</span>{' '}
{providerDisplayName()}
{' · '}
<span class="font-medium text-gray-600">{t('generate.model')}</span>{' '}
{modelDisplayName()}
</p>
</Show>
<p class="mt-2 text-xs text-gray-400">
{t('generate.note')}
</p>
</div>
{/* Theme selection */}
<Show
when={themes().length > 0}
fallback={
<div class="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
<p class="text-sm text-yellow-800">
{t('generate.noThemes')}{' '}
<A href="/themes" class="font-medium underline hover:text-yellow-900">
{t('generate.createThemeLink')}
</A>
</p>
</div>
}
>
<div class="mt-4">
<label for="theme-select" class="block text-sm font-medium text-gray-700">
{t('generate.selectTheme')}
</label>
<select
id="theme-select"
value={selectedThemeId()}
onInput={(e) => setSelectedThemeId(e.currentTarget.value)}
class="mt-1 block w-full sm:w-64 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<For each={themes()}>
{(theme) => (
<option value={theme.id}>{theme.name}</option>
)}
</For>
</select>
</div>
</Show>
{/* No web search warning */}
<Show when={settings().ai_provider && !hasWebSearch()}>
<div class="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<AlertTriangle class="h-5 w-5 text-yellow-600" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
{t('generate.noWebSearchWarning')}
</p>
</div>
</div>
</div>
</Show>
{/* Error display */}
<Show when={error()}>
<div class="mt-4 bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<AlertCircle class="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{error()}</p>
</div>
</div>
</div>
</Show>
{/* Success display */}
<Show when={success()}>
<div class="mt-4 bg-green-50 border-l-4 border-green-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<CheckCircle class="h-5 w-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-green-700">{t('generate.complete')}</p>
</div>
</div>
</div>
</Show>
{/* Progress display */}
<Show when={isInProgress() && sseConnection()}>
<div class="mt-6 space-y-4">
{/* Progress bar */}
<div>
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>{currentMessage()}</span>
<span>{currentPercent()}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-3">
<div
class="bg-indigo-600 rounded-full h-3 transition-all duration-500 ease-out"
style={{ width: `${currentPercent()}%` }}
/>
</div>
</div>
{/* Step checklist */}
<div class="space-y-3 mt-4">
<For each={STEPS}>
{(step) => {
const status = () => stepStatus(step.key);
return (
<div class="flex items-center gap-3">
<Show when={status() === 'done'}>
<CheckCircle class="h-5 w-5 text-green-500 flex-shrink-0" />
</Show>
<Show when={status() === 'in-progress'}>
<Loader2 class="h-5 w-5 text-indigo-600 animate-spin flex-shrink-0" />
</Show>
<Show when={status() === 'pending'}>
<Circle class="h-5 w-5 text-gray-300 flex-shrink-0" />
</Show>
<span
class={`text-sm ${
status() === 'done'
? 'text-green-700'
: status() === 'in-progress'
? 'text-indigo-700 font-medium'
: 'text-gray-400'
}`}
>
{t(step.label as Parameters<typeof t>[0])}
</span>
</div>
);
}}
</For>
</div>
{/* Can leave note */}
<p class="text-xs text-gray-400 mt-4 italic">
{t('generate.canLeave')}
</p>
{/* Stop generation button */}
<Button
variant="danger"
onClick={handleStop}
class="mt-4"
>
{t('generate.stop')}
</Button>
</div>
</Show>
{/* Action buttons */}
<div class="mt-5">
<Show
when={!error()}
fallback={
<Button onClick={handleRetry}>
{t('generate.retry')}
</Button>
}
>
<Button
onClick={handleGenerate}
disabled={generating() || success() || !selectedThemeId()}
loading={generating() && !success()}
>
{generating() && !success()
? t('generate.inProgress')
: t('generate.launch')}
</Button>
</Show>
</div>
</div>
</div>
</div>
</Show>
);
};
export default GenerateSynthesis;