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.
754 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|