import { useEffect, useReducer, useRef, useState } from "preact/hooks"; import { t } from "../../i18n"; import { publicText } from "../../features/scrub/scrub"; import { asString } from "../../features/customize/host"; import { loopbackModelBase, modelProtocolOptions, readCapabilityReceipt, } from "../../features/customize/models"; import { activateExistingModelProfile, activateModelProfile, completeOnboarding, fetchOnboarding, probeModelProfile, saveModelProfile, } from "../../features/onboarding/api"; import { ot } from "../../features/onboarding/copy"; import { INITIAL_WIZARD, REQUIRED_STEPS, checklistItems, formatWizardError, reduceWizard, wizardErrorFromUnknown, type PathChoice, type RequiredStep, type WizardState, } from "../../features/onboarding/machine"; import type { OnboardingStatus } from "../../features/onboarding/status"; import { createProject, openProject } from "../../features/sessions/projects"; import { loadProjects } from "../../features/sessions/load"; import { projects } from "../../stores/session"; import { CapabilityBadges } from "./CapabilityBadges"; import { ReadinessPanel } from "./ReadinessPanel"; import "./onboarding.css"; type Profile = Record; function stepLabel(step: RequiredStep): string { return ot("onboarding.step." + step); } function PathStep({ status, state, busy, onChoose, onSaveNew, }: { status: OnboardingStatus; state: WizardState; busy: boolean; onChoose: (path: PathChoice) => void; onSaveNew: (path: PathChoice, apiKey: string) => void; }) { const keyRef = useRef(null); const protocols = modelProtocolOptions(status.protocols); const [kind, setKind] = useState(state.path?.kind || "existing"); const [provider, setProvider] = useState(state.path?.provider || protocols[0]?.value || "chatgpt"); const [name, setName] = useState(state.path?.name || ""); const [model, setModel] = useState(state.path?.model || ""); const [baseUrl, setBaseUrl] = useState(state.path?.baseUrl || ""); const locals = status.local_model_catalog.endpoints; const submitCloud = () => { const apiKey = keyRef.current?.value || ""; if (keyRef.current) keyRef.current.value = ""; onSaveNew( { kind: "cloud", profileId: "", provider, model: model.trim(), baseUrl: baseUrl.trim(), name: name.trim(), }, apiKey, ); }; return (
{(["existing", "cloud", "local"] as const).map((item) => ( ))}
{kind === "existing" ? ( status.profiles.length ? ( status.profiles.map((raw) => { const p = raw as Profile; const id = asString(p.id); const selected = state.path?.profileId === id; return ( ); }) ) : (
{ot("onboarding.path.empty")}
) ) : null} {kind === "cloud" ? (
setName((e.currentTarget as HTMLInputElement).value)} placeholder={t("cust.models.namePlaceholder")} /> setBaseUrl((e.currentTarget as HTMLInputElement).value)} placeholder={t("cust.models.baseUrlPlaceholder")} /> setModel((e.currentTarget as HTMLInputElement).value)} placeholder={t("cust.models.modelPlaceholder2")} />
) : null} {kind === "local" ? ( locals.length ? ( locals.map((endpoint) => ( )) ) : (
{ot("onboarding.path.empty")}
) ) : null} {kind === "local" && state.path?.kind === "local" ? (
onChoose({ ...state.path!, model: (e.currentTarget as HTMLInputElement).value, }) } />
) : null}
); } function ProjectStep({ busy, onOpen, onCreate, }: { busy: boolean; onOpen: (id: string) => void; onCreate: (name: string) => void; }) { const [name, setName] = useState(""); const [selected, setSelected] = useState(""); const list = projects.value as Array<{ project_id?: string; id?: string; name?: string }>; return (
{list.length ? ( list.map((row) => { const id = row.project_id || row.id || ""; return ( ); }) ) : (
{ot("onboarding.project.empty")}
)} {selected ? ( ) : null} setName((e.currentTarget as HTMLInputElement).value)} placeholder={t("projModal.name.placeholder")} />
); } export function WizardHost() { const [state, dispatch] = useReducer(reduceWizard, INITIAL_WIZARD); const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); const alive = useRef(true); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const load = async () => { setBusy(true); try { const next = await fetchOnboarding(); if (!alive.current) return; setStatus(next); dispatch({ type: "hydrate", complete: next.complete }); } catch (error) { if (!alive.current) return; const fail = wizardErrorFromUnknown(error); dispatch({ type: "fail", message: ot("onboarding.load.err", fail.message), requestId: fail.requestId, }); } finally { if (alive.current) setBusy(false); } }; useEffect(() => { void load(); }, []); useEffect(() => { if (state.step === "project" && state.surface === "wizard") void loadProjects(); }, [state.step, state.surface]); const fail = (error: unknown) => { const next = wizardErrorFromUnknown(error); dispatch({ type: "fail", message: next.message, requestId: next.requestId }); }; const onSkip = async () => { setBusy(true); try { await completeOnboarding({ skip: true }); if (alive.current) dispatch({ type: "skip" }); } catch (error) { if (alive.current) fail(error); } finally { if (alive.current) setBusy(false); } }; const onFinish = async () => { setBusy(true); try { await completeOnboarding({ skip: true }); if (alive.current) dispatch({ type: "finish" }); } catch (error) { if (alive.current) fail(error); } finally { if (alive.current) setBusy(false); } }; const onSaveNew = async (path: PathChoice, apiKey: string) => { const nm = path.name.trim(); if (!nm) { dispatch({ type: "fail", message: t("toast.specialist.enterName"), requestId: "" }); return; } setBusy(true); try { const body: Record = { name: nm, provider: path.provider, base_url: path.baseUrl, model: path.model, }; if (apiKey.trim()) body.api_key = apiKey.trim(); const created = await saveModelProfile(body); const id = asString(created.id); if (id) await activateModelProfile(id); const chosen = { ...path, profileId: id }; dispatch({ type: "choosePath", path: chosen }); const refreshed = await fetchOnboarding(); if (!alive.current) return; setStatus(refreshed); dispatch({ type: "next" }); } catch (error) { if (alive.current) fail(error); } finally { if (alive.current) setBusy(false); } }; const onTest = async () => { const id = state.path?.profileId || status?.active_id || ""; if (!id) { dispatch({ type: "fail", message: ot("onboarding.test.needProfile"), requestId: "" }); return; } dispatch({ type: "startTest" }); setBusy(true); try { const result = await probeModelProfile(id); if (!alive.current) return; const detail = publicText(result.detail, 240); dispatch({ type: "testResult", receipt: readCapabilityReceipt(result.capability_receipt), detail: result.reachable === true ? "" : detail, reachable: result.reachable === true, }); if (result.reachable !== true) { dispatch({ type: "fail", message: detail || t("cust.models.unreachable"), requestId: asString(result.request_id), }); } } catch (error) { if (!alive.current) return; const next = wizardErrorFromUnknown(error); dispatch({ type: "fail", message: next.message, requestId: next.requestId }); dispatch({ type: "testResult", receipt: state.receipt, detail: next.message, reachable: false, }); } finally { if (alive.current) setBusy(false); } }; const onUseExisting = async () => { const id = state.path?.kind === "existing" ? state.path.profileId : ""; if (!id) { dispatch({ type: "fail", message: ot("onboarding.test.needProfile"), requestId: "" }); return; } setBusy(true); try { const refreshed = await activateExistingModelProfile(id); if (!alive.current) return; setStatus(refreshed); dispatch({ type: "next" }); } catch (error) { if (alive.current) fail(error); } finally { if (alive.current) setBusy(false); } }; const onOpenProject = async (id: string) => { setBusy(true); try { await openProject(id); dispatch({ type: "markProjectOpened" }); await onFinish(); } catch (error) { if (alive.current) fail(error); if (alive.current) setBusy(false); } }; const onCreateProject = async (name: string) => { if (!name) { dispatch({ type: "fail", message: ot("onboarding.project.needName"), requestId: "" }); return; } setBusy(true); try { await createProject(name, "", ""); dispatch({ type: "markProjectOpened" }); await onFinish(); } catch (error) { if (alive.current) fail(error); if (alive.current) setBusy(false); } }; if (state.surface === "done") return null; const errorText = formatWizardError(state.error); if (state.surface === "hidden" && !errorText) return null; return ( ); }