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
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;
|