import { Trans, useLingui } from "@lingui/react/macro"; import { DEFAULT_MODEL_CONTEXT_WINDOW, DEFAULT_MODEL_MAX_TOKENS, type IntegrationSetupState, MAX_MODEL_CONTEXT_WINDOW, MAX_MODEL_MAX_TOKENS, OPENAI_COMPATIBLE_PROVIDER_ID, openAiCompatibleConnectReady, openAiCompatibleProbeSuccessMessage, parseModelContextWindow, parseModelMaxImagesPerPrompt, parseModelMaxTokens, type ThinkingLevel, } from "@rakazo/contracts"; import { createModelProbe, initialModelProbeState } from "@rakazo/core"; import { Button, Input, ModelThinkingOptions, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@rakazo/ui-web"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; import type { ModelCatalogEntry } from "../lib/model-auth"; import { computeDiscoveryItems, countConfiguredIntegrations, type OnboardingDiscoveryPhase, } from "../lib/onboarding-discovery"; import { rpc } from "../lib/rpc"; import { useModelOAuthSignIn } from "../lib/use-model-oauth-signin"; import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; const CUSTOM_MODEL_OPTION = "__rakazo_custom_model__"; const FIRST_BOT_NAME = "Chief"; const FIRST_BOT_SPAWN_KEY = "onboarding:first"; const FIRST_BOT_LOCK = "rakazo:onboarding-first-bot"; /** Survives StrictMode remounts; concurrent first-bot creates share one in-flight attempt. */ let firstBotEnsure: Promise<{ id: string }> | null = null; function findFirstBot( bots: Array<{ id: string; name: string; spawnKey: string | null }>, ): { id: string } | undefined { const bySpawnKey = bots.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY); if (bySpawnKey) return { id: bySpawnKey.id }; // Legacy first-run Chief created before spawnKey was set. const byName = bots.find((bot) => bot.name === FIRST_BOT_NAME); return byName ? { id: byName.id } : undefined; } async function createOrReuseFirstBot(): Promise<{ id: string }> { const existing = await rpc.bots.list(); const reuse = findFirstBot(existing); if (reuse) return reuse; try { const created = await rpc.bots.create({ name: FIRST_BOT_NAME, title: "Chief of Staff", description: "Coordinates specialists — routes work, never a do-everything generalist.", instructions: "", notifyOnFinish: true, spawnKey: FIRST_BOT_SPAWN_KEY, }); return { id: created.id }; } catch (error) { // Another tab won the unique (spaceId, spawnKey) race; reuse that bot only. const afterConflict = await rpc.bots.list(); const winner = afterConflict.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY); if (winner) return { id: winner.id }; throw error; } } async function withFirstBotLock(run: () => Promise): Promise { const locks = globalThis.navigator?.locks; if (!locks?.request) return run(); return locks.request(FIRST_BOT_LOCK, run); } async function ensureFirstBot(): Promise<{ id: string }> { if (firstBotEnsure) return firstBotEnsure; // Web Lock serializes cross-tab creates; module promise covers same-tab StrictMode. // spawnKey makes create idempotent when locks are unavailable. // Clear after settle so a later empty-space visit re-lists instead of reusing a deleted id. firstBotEnsure = withFirstBotLock(createOrReuseFirstBot).finally(() => { firstBotEnsure = null; }); return firstBotEnsure; } function providerLabel(entry: ModelCatalogEntry): string { return entry.provider === "openai-codex" ? "ChatGPT" : (entry.providerName ?? entry.provider); } function nextStepAfterModel(needsIntegrationSetup: boolean): "integrations" | "bot" { return needsIntegrationSetup ? "integrations" : "bot"; } export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); const fieldId = useId(); const [step, setStep] = useState<"loading" | "model" | "integrations" | "bot">("loading"); const [integrationSetup, setIntegrationSetup] = useState(null); const needsIntegrationSetup = integrationSetup?.needsSetup ?? false; const [integrationServers, setIntegrationServers] = useState([]); const [catalog, setCatalog] = useState([]); const [provider, setProvider] = useState("openrouter"); const [modelId, setModelId] = useState(""); const [apiKey, setApiKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [reasoning, setReasoning] = useState(false); const [manualModelId, setManualModelId] = useState(false); const [thinkingLevel, setThinkingLevel] = useState(null); const [maxTokens, setMaxTokens] = useState(String(DEFAULT_MODEL_MAX_TOKENS)); const [contextWindow, setContextWindow] = useState(String(DEFAULT_MODEL_CONTEXT_WINDOW)); const [supportsImages, setSupportsImages] = useState(false); const [maxImagesPerPrompt, setMaxImagesPerPrompt] = useState(""); const [{ models: probeModels, probing }, setProbe] = useState(initialModelProbeState); const [modelProbe] = useState(() => createModelProbe(setProbe)); const resetOpenAiCompatibleProbe = modelProbe.reset; const createStartedRef = useRef(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [bootstrapMe, setBootstrapMe] = useState> | null>(null); const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); const [helpVibe, setHelpVibe] = useState(null); const { oauth, pasteCode, setPasteCode, oauthPending, cancelOAuthAttempt, startSubscriptionSignIn, submitOAuthCode, } = useModelOAuthSignIn({ onClearError: () => setError(null), onError: setError, onFinished: () => { setStep(nextStepAfterModel(needsIntegrationSetup)); }, }); useEffect(() => { void Promise.all([ rpc.me(), rpc.models.list().catch(() => []), rpc.integrationSetup.get().catch(() => null), ]) .then(([me, models, integrations]) => { setBootstrapMe(me); setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); setIntegrationSetup(integrations); setCatalog(models); const preferred = models.find( (entry) => entry.provider === me.defaultProvider && entry.id === me.defaultModel, ) ?? models.find((entry) => entry.provider === me.defaultProvider) ?? models[0]; if (preferred) { setProvider(preferred.provider); setModelId(preferred.provider === OPENAI_COMPATIBLE_PROVIDER_ID ? "" : preferred.id); } setStep(me.needsModel ? "model" : integrations?.needsSetup ? "integrations" : "bot"); }) .catch(() => setStep("bot")); return () => { modelProbe.invalidate(); }; }, []); const providers = useMemo(() => { const seen = new Map(); for (const entry of catalog) { if (!seen.has(entry.provider)) seen.set(entry.provider, entry); } return [...seen.values()]; }, [catalog]); const modelsForProvider = useMemo( () => catalog.filter((entry) => entry.provider === provider), [catalog, provider], ); const selected = modelsForProvider.find((entry) => entry.id === modelId) ?? modelsForProvider[0]; const isOpenAiCompatible = provider === OPENAI_COMPATIBLE_PROVIDER_ID; const subscriptionSignIn = selected?.signIn !== undefined; const acceptsKey = selected?.auth !== "oauth"; const signInLabel = selected?.oauthLabel ?? t`Sign in`; const openAiCompatibleReady = openAiCompatibleConnectReady({ baseUrl, modelId, }); const canSaveModel = Boolean( selected && modelId.trim() && !oauthPending && (isOpenAiCompatible ? openAiCompatibleReady : acceptsKey && apiKey.trim()), ); const otherModelLabel = t`Other model…`; // Base UI Select.Value only resolves labels when Root gets `items`. const providerItems = useMemo( () => providers.map((entry) => ({ value: entry.provider, label: providerLabel(entry) })), [providers], ); const modelItems = useMemo( () => modelsForProvider.map((entry) => ({ value: entry.id, label: entry.label })), [modelsForProvider], ); const probeModelItems = useMemo( () => [ ...probeModels.map((id) => ({ value: id, label: id })), { value: CUSTOM_MODEL_OPTION, label: otherModelLabel }, ], [otherModelLabel, probeModels], ); function updateBaseUrl(nextBaseUrl: string) { setBaseUrl(nextBaseUrl); // Keep Other model… mode across URL edits; only provider change clears it. resetOpenAiCompatibleProbe(); setError(null); setNotice(null); } function updateApiKey(nextApiKey: string) { setApiKey(nextApiKey); resetOpenAiCompatibleProbe(); } function selectProvider(nextProvider: string) { if (nextProvider === provider) return; cancelOAuthAttempt(); setProvider(nextProvider); setApiKey(""); setModelId( nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID ? "" : (catalog.find((item) => item.provider === nextProvider)?.id ?? ""), ); setBaseUrl(""); setReasoning(false); setThinkingLevel(null); setManualModelId(false); setSupportsImages(false); setMaxTokens(String(DEFAULT_MODEL_MAX_TOKENS)); setContextWindow(String(DEFAULT_MODEL_CONTEXT_WINDOW)); setMaxImagesPerPrompt(""); resetOpenAiCompatibleProbe(); setError(null); setNotice(null); } async function probeServerModels() { if (!baseUrl.trim()) return; setError(null); setNotice(null); await modelProbe.probe({ baseUrl, apiKey, request: rpc.models.probeOpenAiCompatible, onSuccess: (models) => { setModelId((current) => { const trimmed = current.trim(); const next = trimmed || models[0] || ""; // Stay in manual entry across re-probes so a typed id that matches a // discovered model cannot yank the freeform field back to the Select. setManualModelId( (wasManual) => wasManual || (Boolean(trimmed) && !models.includes(trimmed)), ); return next; }); setNotice(openAiCompatibleProbeSuccessMessage(models.length)); }, onError: (err) => setError(err instanceof Error ? err.message : t`Could not reach this model server`), }); } async function saveModel() { if (!canSaveModel) return; setError(null); try { if (isOpenAiCompatible) { const parsedMaxImagesPerPrompt = parseModelMaxImagesPerPrompt( maxImagesPerPrompt, supportsImages, ); if (supportsImages && maxImagesPerPrompt.trim() && parsedMaxImagesPerPrompt === undefined) { setError(t`Enter a whole number from 1 to 1000 for the image limit.`); return; } const maxImagesPerPromptInput = supportsImages && !maxImagesPerPrompt.trim() ? null : parsedMaxImagesPerPrompt; const parsedMaxTokens = parseModelMaxTokens(maxTokens); if (parsedMaxTokens === undefined) { setError( t`Enter a whole number from 1 to ${MAX_MODEL_MAX_TOKENS} for maximum output tokens.`, ); return; } const parsedContextWindow = parseModelContextWindow(contextWindow); if (parsedContextWindow === undefined) { setError( t`Enter a whole number from 1 to ${MAX_MODEL_CONTEXT_WINDOW} for the context limit.`, ); return; } await rpc.models.connect({ provider, baseUrl: baseUrl.trim(), modelId: modelId.trim(), reasoning, thinkingLevel: reasoning ? thinkingLevel : null, maxTokens: parsedMaxTokens, contextWindow: parsedContextWindow, supportsImages, maxImagesPerPrompt: maxImagesPerPromptInput, apiKey: apiKey.trim() || undefined, label: selected?.providerName ?? provider, }); } else if (apiKey) { await rpc.models.connect({ provider, apiKey, modelId, label: selected?.providerName ?? provider, }); } setStep(nextStepAfterModel(needsIntegrationSetup)); } catch (err) { setError(err instanceof Error ? err.message : t`Could not save model`); } } function beginSelectedSubscriptionSignIn() { void startSubscriptionSignIn({ provider, modelId, label: selected?.providerName ?? provider, }); } const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" : step === "model" ? "model" : step === "integrations" ? "integrations" : "assembly"; const discoveryItems = useMemo( () => computeDiscoveryItems({ phase: discoveryPhase, me: bootstrapMe, integrationSetup, connectedIntegrationCount: Math.max( integrationServers.length, countConfiguredIntegrations(integrationSetup), ), profilePersisted, crewReady, }), [ bootstrapMe, crewReady, discoveryPhase, integrationServers.length, integrationSetup, profilePersisted, ], ); async function createFirstBot() { if (createStartedRef.current) return; createStartedRef.current = true; setError(null); try { const bot = await ensureFirstBot(); setCrewReady(true); const me = await rpc.me().catch(() => null); if (me) setBootstrapMe(me); const harvested = await persistUserProfileHarvest( rpc, collectLocalProfileSignals(me ?? undefined), ).catch(() => null); if (harvested) setHelpVibe(harvested); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } // Onboarding continues conversationally in the thread: greeting first, // then the focus choice (immediate for the first bot). const started = await rpc.onboarding .start({ botId: bot.id }) .then(() => true) .catch(() => false); if (started) { await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); } navigate(`/app/${bot.id}`); } catch (err) { createStartedRef.current = false; setError(err instanceof Error ? err.message : t`Could not create your bot`); } } useEffect(() => { if (step !== "bot") return; void createFirstBot(); }, [step]); return (
{step === "model" ? (

One connection — then we take it from here

Your crew needs a model to think. This is the only setup step we ask for upfront.

Provider
{isOpenAiCompatible ? ( <>
Model {probeModels.length && !manualModelId ? ( ) : ( { setManualModelId(true); setModelId(e.target.value); }} aria-label={t`Model id`} placeholder="exact-model-id" className="mt-2" /> )} {probeModels.length && manualModelId ? ( ) : null}
{ setReasoning(value); if (!value) setThinkingLevel(null); }} advancedLabel={t`Advanced`} thinkingLabel={t`Supports thinking`} thinkingLevel={thinkingLevel} onThinkingLevelChange={(value) => setThinkingLevel(value as ThinkingLevel | null) } thinkingLevelOptions={[ { value: "minimal", label: t`Minimal` }, { value: "low", label: t`Low` }, { value: "medium", label: t`Medium` }, { value: "high", label: t`High` }, { value: "xhigh", label: t`Extra high` }, { value: "max", label: t`Max` }, ]} thinkingLevelLabel={t`Reasoning effort`} thinkingLevelDefaultLabel={t`Default`} maxTokens={maxTokens} onMaxTokensChange={setMaxTokens} maxTokensLabel={t`Maximum output tokens`} contextWindow={contextWindow} onContextWindowChange={setContextWindow} contextWindowLabel={t`Context limit`} supportsImages={supportsImages} onSupportsImagesChange={setSupportsImages} imagesLabel={t`Supports images`} maxImagesPerPrompt={maxImagesPerPrompt} onMaxImagesPerPromptChange={setMaxImagesPerPrompt} maxImagesLabel={t`Maximum images per request`} /> ) : ( <> Model )}
{subscriptionSignIn ? (
{oauth ? (
{oauth.mode === "auth-url" ? ( <>

Finish signing in at{" "} {new URL(oauth.verificationUri).hostname} . The final page may not load; paste its URL or code here.

setPasteCode(e.target.value)} aria-label={t`Authorization code or callback URL`} autoComplete="off" spellCheck={false} placeholder="http://localhost:53692/callback?code=…" />

Waiting for sign-in…

) : ( <>

Enter this code at{" "} {oauth.verificationUri.replace(/^https:\/\//, "")}

{oauth.userCode}

Waiting for sign-in…

)}
) : ( )}
) : null} {acceptsKey ? ( isOpenAiCompatible ? (
API key updateApiKey(e.target.value)} placeholder={t`Optional`} type="password" autoComplete="new-password" className="mt-2" />
) : ( ) ) : null} {notice ?

{notice}

: null} {error ?

{error}

: null}
) : null} {step === "integrations" ? (

Connect tools when you're ready

setStep("bot")} onServerConnected={(id) => setIntegrationServers((current) => [...new Set([...current, id])]) } />
) : null} {step === "bot" && error ? (

{error}

) : null}
); }