evOo-local/apps/web/src/pages/Onboarding.tsx
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

754 lines
28 KiB
TypeScript

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<T>(run: () => Promise<T>): Promise<T> {
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<IntegrationSetupState | null>(null);
const needsIntegrationSetup = integrationSetup?.needsSetup ?? false;
const [integrationServers, setIntegrationServers] = useState<string[]>([]);
const [catalog, setCatalog] = useState<ModelCatalogEntry[]>([]);
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<ThinkingLevel | null>(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<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
const [profilePersisted, setProfilePersisted] = useState(false);
const [crewReady, setCrewReady] = useState(false);
const [helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(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<string, ModelCatalogEntry>();
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 (
<div className="min-h-full bg-background px-6 py-12" data-rakazo-surface="onboarding">
<OnboardingDiscoveryShell
items={discoveryItems}
compact={step === "model" || step === "integrations"}
>
{step === "model" ? (
<div>
<h2 className="text-lg font-medium text-foreground">
<Trans>One connection — then we take it from here</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>
Your crew needs a model to think. This is the only setup step we ask for upfront.
</Trans>
</p>
<div className="mt-6 block text-sm font-medium text-foreground">
<span>
<Trans>Provider</Trans>
</span>
<Select
value={provider}
onValueChange={(value) => selectProvider(String(value))}
items={providerItems}
>
<SelectTrigger aria-label={t`Provider`} className="mt-2 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{providers.map((entry) => (
<SelectItem key={entry.provider} value={entry.provider}>
{providerLabel(entry)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-6 block text-sm text-foreground">
{isOpenAiCompatible ? (
<>
<label htmlFor={`${fieldId}-base-url`} className="block font-medium">
<Trans>Server URL</Trans>
<Input
id={`${fieldId}-base-url`}
value={baseUrl}
onChange={(e) => updateBaseUrl(e.target.value)}
aria-label={t`OpenAI-compatible server URL`}
placeholder="http://127.0.0.1:8000/v1"
autoComplete="off"
className="mt-2"
/>
</label>
<div className="mt-3">
<Button
variant="outline"
disabled={probing || !baseUrl.trim()}
onClick={() => void probeServerModels()}
>
{probing ? <Trans>Finding…</Trans> : <Trans>Find models</Trans>}
</Button>
</div>
<div className="mt-4 block">
<span className="font-medium">
<Trans>Model</Trans>
</span>
{probeModels.length && !manualModelId ? (
<Select
value={modelId}
onValueChange={(value) => {
const next = String(value);
if (next === CUSTOM_MODEL_OPTION) {
setManualModelId(true);
setModelId("");
} else {
setManualModelId(false);
setModelId(next);
}
}}
items={probeModelItems}
>
<SelectTrigger aria-label={t`Models from server`} className="mt-2 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{probeModels.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
<SelectItem value={CUSTOM_MODEL_OPTION}>
<Trans>Other model…</Trans>
</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={modelId}
onChange={(e) => {
setManualModelId(true);
setModelId(e.target.value);
}}
aria-label={t`Model id`}
placeholder="exact-model-id"
className="mt-2"
/>
)}
{probeModels.length && manualModelId ? (
<Button
variant="link"
size="xs"
className="mt-2 px-0 text-muted-foreground"
onClick={() => {
setManualModelId(false);
setModelId(probeModels[0] ?? "");
}}
>
<Trans>Use a found model</Trans>
</Button>
) : null}
</div>
<ModelThinkingOptions
reasoning={reasoning}
onReasoningChange={(value) => {
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`}
/>
</>
) : (
<>
<span className="font-medium">
<Trans>Model</Trans>
</span>
<Select
value={selected?.id ?? modelId}
onValueChange={(value) => {
const next = String(value);
if (next === modelId) return;
cancelOAuthAttempt();
setModelId(next);
}}
items={modelItems}
>
<SelectTrigger aria-label={t`Model`} className="mt-2 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((entry) => (
<SelectItem key={`${entry.provider}:${entry.id}`} value={entry.id}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
{subscriptionSignIn ? (
<div className="mt-4">
{oauth ? (
<div className="rounded-lg border border-border px-3.5 py-3">
{oauth.mode === "auth-url" ? (
<>
<p className="text-sm text-muted-foreground">
<Trans>
Finish signing in at{" "}
<a
href={oauth.verificationUri}
target="_blank"
rel="noreferrer"
className="text-foreground underline"
>
{new URL(oauth.verificationUri).hostname}
</a>
. The final page may not load; paste its URL or code here.
</Trans>
</p>
<div className="mt-3 flex items-center gap-2">
<Input
value={pasteCode}
onChange={(e) => setPasteCode(e.target.value)}
aria-label={t`Authorization code or callback URL`}
autoComplete="off"
spellCheck={false}
placeholder="http://localhost:53692/callback?code=…"
/>
<Button
disabled={!pasteCode.trim()}
onClick={() => void submitOAuthCode()}
>
<Trans>Submit</Trans>
</Button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Waiting for sign-in…</Trans>
</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
<Trans>
Enter this code at{" "}
<a
href={oauth.verificationUri}
target="_blank"
rel="noreferrer"
className="text-foreground underline"
>
{oauth.verificationUri.replace(/^https:\/\//, "")}
</a>
</Trans>
</p>
<p className="mt-2 font-mono text-[22px] tracking-[0.2em] text-foreground">
{oauth.userCode}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Waiting for sign-in…</Trans>
</p>
</>
)}
</div>
) : (
<Button disabled={oauthPending} onClick={() => beginSelectedSubscriptionSignIn()}>
{oauthPending ? <Trans>Starting…</Trans> : signInLabel}
</Button>
)}
</div>
) : null}
{acceptsKey ? (
isOpenAiCompatible ? (
<details className="mt-4 text-sm text-muted-foreground">
<summary className="w-fit cursor-pointer select-none">
<Trans>API key</Trans>
</summary>
<Input
aria-label={t`API key`}
value={apiKey}
onChange={(e) => updateApiKey(e.target.value)}
placeholder={t`Optional`}
type="password"
autoComplete="new-password"
className="mt-2"
/>
</details>
) : (
<label
htmlFor={`${fieldId}-api-key`}
className="mt-4 block text-sm font-medium text-foreground"
>
{subscriptionSignIn ? <Trans>Or paste an API key</Trans> : <Trans>API key</Trans>}
<Input
id={`${fieldId}-api-key`}
value={apiKey}
onChange={(e) => updateApiKey(e.target.value)}
placeholder="sk-…"
type="password"
autoComplete="new-password"
className="mt-2"
/>
</label>
)
) : null}
{notice ? <p className="mt-3 text-sm text-success">{notice}</p> : null}
{error ? <p className="mt-3 text-sm text-destructive">{error}</p> : null}
<div className="mt-6 flex gap-3">
<Button disabled={!canSaveModel} onClick={() => void saveModel()}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
) : null}
{step === "integrations" ? (
<div>
<h2 className="mb-4 text-lg font-medium text-foreground">
<Trans>Connect tools when you're ready</Trans>
</h2>
<IntegrationSetup
serverSetup
initialState={integrationSetup}
onDone={() => setStep("bot")}
onServerConnected={(id) =>
setIntegrationServers((current) => [...new Set([...current, id])])
}
/>
</div>
) : null}
{step === "bot" && error ? (
<div className="text-center">
<p className="text-sm text-destructive">{error}</p>
<Button className="mt-4" onClick={() => void createFirstBot()}>
<Trans>Try again</Trans>
</Button>
</div>
) : null}
</OnboardingDiscoveryShell>
</div>
);
}