import {
type FormEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import {
ArrowRightIcon,
CheckIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
Trash2Icon,
TriangleAlertIcon,
} from "lucide-react";
import { DirBrowserDialog } from "@/components/dir-browser-dialog";
import { AgentExpression } from "@/components/agent-expression";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
completeSetupProviderAuthDevice,
discoverSetupModels,
runSetupProviderAuth,
saveSetupConfig,
startSetupProviderAuthDevice,
type ConfigReadinessReport,
type SetupConfigRequest,
type SetupDiscoveredModel,
type SetupModelRequest,
type SetupProviderAuthStartResponse,
type SetupProviderKind,
type SetupProviderRequest,
} from "@/lib/daemon-api";
import {
getCurrentWebUiLanguage,
normalizeWebUiLocale,
setWebUiLanguage,
webUiLocaleOptions,
type WebUiLocale,
} from "@/lib/i18n";
import { cn } from "@/lib/utils";
type SaveState = "idle" | "saving" | "error";
type SetupStep = "intro" | "personalization" | "configuration";
type ProviderDialogMode = "add" | "edit";
type ModelDialogMode = "add" | "edit";
type ProviderAuthState = "idle" | "running" | "device_pending" | "success" | "error";
type ThinkingSelection = "__unset__" | "__custom__" | string;
type CodexAuthMethod =
| "browser_login"
| "device_login"
| "import_local_codex"
| "import_auth_file"
| "existing_auth_file";
type GithubAuthMethod = "device_login" | "manual_token" | "env_token";
type SupportsVisionValue = "auto" | "true" | "false";
type ApiStyleValue = "chat_completions" | "responses";
export type SetupProviderDraft = {
id: string;
name: string;
kind: SetupProviderKind;
apiKey: string;
baseUrl: string;
keepAlive: string;
codexAuthMethod: CodexAuthMethod;
codexAuthFile: string;
githubAuthMethod: GithubAuthMethod;
};
export type SetupModelDraft = {
id: string;
name: string;
providerName: string;
modelId: string;
contextWindowTokens: string;
maxCompletionTokens: string;
supportsVision: SupportsVisionValue;
thinkingBudget: string;
apiStyle: ApiStyleValue;
source?: SetupModelRequest;
};
type ProviderDialogState = {
mode: ProviderDialogMode;
provider: SetupProviderDraft | null;
};
type ModelDialogState = {
mode: ModelDialogMode;
model: SetupModelDraft | null;
};
export type ModelAccessEditorValue = {
providers: SetupProviderDraft[];
models: SetupModelDraft[];
mainModel: string;
efficientModel: string;
};
export type AgentPersonalizationEditorValue = {
personaName: string;
personaLanguage: string;
identitySummary: string;
};
type ModelAccessEditorProps = {
value: ModelAccessEditorValue;
onChange: (value: ModelAccessEditorValue) => void;
submitSlot?: ReactNode;
providerDescription?: string;
modelDescription?: string;
selectionDescription?: string;
fieldGroupClassName?: string;
};
type AgentPersonalizationEditorProps = {
value: AgentPersonalizationEditorValue;
onChange: (value: AgentPersonalizationEditorValue) => void;
title?: string;
description?: string | null;
showHeader?: boolean;
className?: string;
fieldGroupClassName?: string;
};
type SetupPageProps = {
readiness: ConfigReadinessReport;
onReadinessChanged: (readiness: ConfigReadinessReport) => void;
onSaveSetupConfig?: (
request: SetupConfigRequest,
) => Promise<ConfigReadinessReport>;
};
const PROVIDER_KIND_ORDER: SetupProviderKind[] = [
"openai",
"openai_codex_oauth",
"github_copilot",
"openai_compatible",
"ollama",
"ollama_cloud",
];
const CODEX_AUTH_METHOD_ORDER: CodexAuthMethod[] = [
"browser_login",
"device_login",
"import_local_codex",
"import_auth_file",
"existing_auth_file",
];
const GITHUB_AUTH_METHOD_ORDER: GithubAuthMethod[] = [
"device_login",
"manual_token",
"env_token",
];
function providerKindOption(kind: SetupProviderKind, t: TFunction) {
return {
value: kind,
label: t(`setup.modelAccess.providerKinds.${kind}.label`),
description: t(`setup.modelAccess.providerKinds.${kind}.description`),
};
}
function codexAuthMethodOption(method: CodexAuthMethod, t: TFunction) {
return {
value: method,
label: t(`setup.modelAccess.codexAuthMethods.${method}.label`),
description: t(`setup.modelAccess.codexAuthMethods.${method}.description`),
};
}
function githubAuthMethodOption(method: GithubAuthMethod, t: TFunction) {
return {
value: method,
label: t(`setup.modelAccess.githubAuthMethods.${method}.label`),
description: t(`setup.modelAccess.githubAuthMethods.${method}.description`),
};
}
const PERSONA_LANGUAGES = [
{ value: "zh-CN", label: "Simplified Chinese", greeting: "你好" },
{ value: "zh-TW", label: "Traditional Chinese", greeting: "你好" },
{ value: "en-US", label: "English", greeting: "Hello" },
{ value: "ja-JP", label: "Japanese", greeting: "こんにちは" },
{ value: "ko-KR", label: "Korean", greeting: "안녕하세요" },
{ value: "fr-FR", label: "French", greeting: "Bonjour" },
{ value: "de-DE", label: "German", greeting: "Hallo" },
{ value: "es-ES", label: "Spanish", greeting: "Hola" },
{ value: "pt-BR", label: "Portuguese (Brazil)", greeting: "Olá" },
{ value: "ru-RU", label: "Russian", greeting: "Привет" },
{ value: "it-IT", label: "Italian", greeting: "Ciao" },
{ value: "nl-NL", label: "Dutch", greeting: "Hallo" },
{ value: "tr-TR", label: "Turkish", greeting: "Merhaba" },
{ value: "pl-PL", label: "Polish", greeting: "Cześć" },
{ value: "uk-UA", label: "Ukrainian", greeting: "Привіт" },
{ value: "ar", label: "Arabic", greeting: "مرحبا" },
{ value: "hi-IN", label: "Hindi", greeting: "नमस्ते" },
{ value: "id-ID", label: "Indonesian", greeting: "Halo" },
{ value: "vi-VN", label: "Vietnamese", greeting: "Xin chào" },
{ value: "th-TH", label: "Thai", greeting: "สวัสดี" },
];
const DEFAULT_CONTEXT_WINDOW_TOKENS = "200000";
const DEFAULT_MAX_COMPLETION_TOKENS = "32768";
const THINKING_UNSET_VALUE = "__unset__";
const THINKING_CUSTOM_VALUE = "__custom__";
export function SetupPage({
readiness,
onReadinessChanged,
onSaveSetupConfig = saveSetupConfig,
}: SetupPageProps) {
const { t } = useTranslation();
const [webUiLocale, setWebUiLocale] = useState<WebUiLocale>(
getCurrentWebUiLanguage,
);
const [step, setStep] = useState<SetupStep>("intro");
const [agentPersonalization, setAgentPersonalization] =
useState<AgentPersonalizationEditorValue>(() =>
createDefaultAgentPersonalizationEditorValue(),
);
const personaLanguage = agentPersonalization.personaLanguage;
const [personalizationPreview, setPersonalizationPreview] = useState("Hello");
const [personalizationPreviewVisible, setPersonalizationPreviewVisible] =
useState(true);
const [modelAccess, setModelAccess] = useState<ModelAccessEditorValue>(() =>
createDefaultModelAccessEditorValue(),
);
const [saveState, setSaveState] = useState<SaveState>("idle");
const [saveError, setSaveError] = useState<string | null>(null);
const isSaving = saveState === "saving";
const { providers, models, mainModel, efficientModel } = modelAccess;
const mainModelMissing = !models.some((model) => model.name === mainModel);
const efficientModelMissing = !models.some(
(model) => model.name === efficientModel,
);
const canCompleteSetup =
providers.length > 0 &&
models.length > 0 &&
!mainModelMissing &&
!efficientModelMissing;
const nextPersonalizationPreview =
PERSONA_LANGUAGES.find((language) => language.value === personaLanguage)
?.greeting ?? "Hello";
useEffect(() => {
if (personalizationPreview === nextPersonalizationPreview) {
return;
}
setPersonalizationPreviewVisible(false);
const timeoutId = window.setTimeout(() => {
setPersonalizationPreview(nextPersonalizationPreview);
setPersonalizationPreviewVisible(true);
}, 140);
return () => window.clearTimeout(timeoutId);
}, [nextPersonalizationPreview, personalizationPreview]);
function handleWebUiLocaleChange(locale: string) {
const nextLocale = normalizeWebUiLocale(locale);
setWebUiLocale(nextLocale);
void setWebUiLanguage(nextLocale);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (providers.length === 0) {
setSaveState("error");
setSaveError(t("setup.validation.providerRequired"));
return;
}
if (models.length === 0) {
setSaveState("error");
setSaveError(t("setup.validation.modelRequired"));
return;
}
if (mainModelMissing || efficientModelMissing) {
setSaveState("error");
setSaveError(t("setup.validation.mainAndEfficientModelsRequired"));
return;
}
const request: SetupConfigRequest = {
...agentPersonalizationEditorValueToSetupRequest(agentPersonalization),
...modelAccessEditorValueToSetupRequest(modelAccess),
daemon_port: readiness.port,
locale: webUiLocale,
};
setSaveState("saving");
setSaveError(null);
try {
const nextReadiness = await onSaveSetupConfig(request);
onReadinessChanged(nextReadiness);
setSaveState(nextReadiness.kind === "complete" ? "idle" : "error");
setSaveError(
nextReadiness.kind === "complete" ? null : nextReadiness.message,
);
} catch (error) {
setSaveState("error");
setSaveError(error instanceof Error ? error.message : String(error));
}
}
if (step === "intro") {
return (
<section
id="setup"
aria-label={t("setup.intro.pageAria")}
className="flex min-h-screen w-full bg-background px-6 py-10"
>
<div className="flex w-full flex-col justify-between px-[8vw] py-[8vh]">
<div className="flex flex-col gap-24">
<div className="flex flex-col gap-10">
<h1 className="text-7xl font-medium tracking-normal">
{t("setup.intro.greeting")}
</h1>
<FieldGroup className="max-w-xs">
<Field>
<FieldLabel htmlFor="setup-webui-language">
{t("setup.intro.languageLabel")}
</FieldLabel>
<Select
value={webUiLocale}
onValueChange={handleWebUiLocaleChange}
>
<SelectTrigger id="setup-webui-language" className="w-full">
<SelectValue
placeholder={t("setup.intro.languagePlaceholder")}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{webUiLocaleOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
{t("setup.intro.languageDescription")}
</FieldDescription>
</Field>
</FieldGroup>
</div>
<div className="flex max-w-2xl flex-col gap-3">
<p className="text-3xl leading-relaxed text-foreground">
{t("setup.intro.notConfigured")}
</p>
<p className="text-3xl leading-relaxed text-foreground">
{t("setup.intro.wizardGuide")}
</p>
</div>
</div>
<div>
<Button
type="button"
size="icon"
className="size-12 rounded-full"
aria-label={t("setup.intro.next")}
onClick={() => setStep("personalization")}
>
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Button>
</div>
</div>
</section>
);
}
if (step === "personalization") {
return (
<section
id="setup"
aria-label={t("setup.personalization.pageAria")}
className="flex min-h-screen w-full bg-background px-6 py-10"
>
<div className="flex w-full flex-col justify-between px-[8vw] py-[8vh]">
<div className="grid flex-1 grid-cols-1 gap-16 lg:grid-cols-12">
<div className="flex flex-col gap-24 lg:col-span-6">
<h1 className="text-7xl font-medium tracking-normal">
{t("setup.personalization.title")}
</h1>
<AgentPersonalizationEditor
value={agentPersonalization}
onChange={(nextValue) => {
setAgentPersonalization(nextValue);
}}
showHeader={false}
className="max-w-xl"
/>
</div>
<div className="flex items-start justify-center pt-6 lg:col-span-4 lg:col-start-8 lg:justify-center lg:pt-36">
<div className="flex flex-col items-center gap-10">
<AgentExpression
status="idle"
className="w-44 p-0 sm:w-52"
/>
<p
className={cn(
"text-7xl font-medium leading-none tracking-normal text-foreground transition-opacity duration-200",
personalizationPreviewVisible ? "opacity-100" : "opacity-0",
)}
>
{personalizationPreview}
</p>
</div>
</div>
</div>
<div>
<Button
type="button"
size="icon"
className="size-14 rounded-full"
aria-label={t("setup.personalization.next")}
onClick={() => setStep("configuration")}
>
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Button>
</div>
</div>
</section>
);
}
return (
<section
id="setup"
aria-label={t("setup.configuration.pageAria")}
className="flex min-h-screen w-full bg-background px-6 py-10"
>
<form
onSubmit={handleSubmit}
className="mx-auto flex w-full max-w-5xl flex-col px-[6vw] py-[7vh]"
>
<div className="flex flex-col gap-14">
<div className="flex flex-col gap-16">
<h1 className="text-7xl font-medium tracking-normal">
{t("setup.configuration.title")}
</h1>
<div className="flex max-w-3xl flex-col gap-3">
<p className="text-3xl leading-relaxed text-foreground">
{t("setup.configuration.description")}
</p>
</div>
</div>
<div className="flex flex-col gap-4">
{readiness.recovery_note ? (
<Alert>
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>{t("setup.configuration.configRestored")}</AlertTitle>
<AlertDescription>{readiness.recovery_note}</AlertDescription>
</Alert>
) : null}
{saveState === "error" && saveError ? (
<Alert variant="destructive">
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>{t("setup.configuration.unableToSave")}</AlertTitle>
<AlertDescription>{saveError}</AlertDescription>
</Alert>
) : null}
</div>
<ModelAccessEditor
value={modelAccess}
onChange={(nextValue) => {
setModelAccess(nextValue);
setSaveState("idle");
setSaveError(null);
}}
submitSlot={
canCompleteSetup ? (
<div className="flex justify-start pt-4">
<Button
type="submit"
size="icon"
className="size-14 rounded-full"
disabled={isSaving}
aria-label={
isSaving
? t("setup.configuration.completingSetup")
: t("setup.configuration.completeSetup")
}
>
{isSaving ? (
<Spinner data-icon="inline-start" />
) : (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
)}
</Button>
</div>
) : null
}
/>
</div>
</form>
</section>
);
}
export function AgentPersonalizationEditor({
value,
onChange,
title,
description,
showHeader = true,
className,
fieldGroupClassName,
}: AgentPersonalizationEditorProps) {
const { t } = useTranslation();
const agentDisplayName = displayAgentName(value.personaName);
const sectionTitle =
title ?? t("setup.personalization.customize", { agent: agentDisplayName });
const sectionDescription =
description === undefined
? t("setup.personalization.defaultDescription")
: description;
return (
<section className={cn("flex flex-col gap-6", className)}>
{showHeader ? (
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-medium tracking-normal">
{sectionTitle}
</h2>
{sectionDescription ? (
<p className="max-w-3xl text-base text-muted-foreground">
{sectionDescription}
</p>
) : null}
</div>
) : null}
<FieldGroup className={cn("max-w-xl", fieldGroupClassName)}>
<Field>
<FieldLabel htmlFor="agent-personalization-language">
{t("setup.personalization.languageForAgent", {
agent: agentDisplayName,
})}
</FieldLabel>
<Select
value={normalizePersonaLanguage(value.personaLanguage)}
onValueChange={(personaLanguage) =>
onChange({ ...value, personaLanguage })
}
>
<SelectTrigger
id="agent-personalization-language"
className="w-full"
>
<SelectValue placeholder={t("setup.personalization.languagePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PERSONA_LANGUAGES.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="agent-personalization-name">
{t("setup.personalization.agentName", { agent: agentDisplayName })}
</FieldLabel>
<Input
id="agent-personalization-name"
value={value.personaName}
onChange={(event) =>
onChange({ ...value, personaName: event.target.value })
}
placeholder="DaatLocus"
spellCheck={false}
/>
</Field>
<Field>
<FieldLabel htmlFor="agent-personalization-identity-summary">
{t("setup.personalization.personaContent")}
</FieldLabel>
<Textarea
id="agent-personalization-identity-summary"
value={value.identitySummary}
onChange={(event) =>
onChange({ ...value, identitySummary: event.target.value })
}
placeholder={DEFAULT_PERSONA_IDENTITY_SUMMARY}
className="min-h-28"
spellCheck={false}
/>
<FieldDescription>
{t("setup.personalization.personaContentDescription", {
token: "{{name}}",
})}
</FieldDescription>
</Field>
</FieldGroup>
</section>
);
}
export function ModelAccessEditor({
value,
onChange,
submitSlot = null,
providerDescription,
modelDescription,
selectionDescription,
fieldGroupClassName,
}: ModelAccessEditorProps) {
const { t } = useTranslation();
const providerSectionDescription =
providerDescription ?? t("setup.modelAccess.providerDescription");
const modelSectionDescription =
modelDescription ?? t("setup.modelAccess.modelDescription");
const selectionSectionDescription =
selectionDescription ?? t("setup.modelAccess.selectionDescription");
const [providerDialog, setProviderDialog] =
useState<ProviderDialogState | null>(null);
const [modelDialog, setModelDialog] = useState<ModelDialogState | null>(null);
const { providers, models, mainModel, efficientModel } = value;
const modelNames = models.map((model) => model.name);
const mainModelMissing = !models.some((model) => model.name === mainModel);
const efficientModelMissing = !models.some(
(model) => model.name === efficientModel,
);
function updateValue(nextValue: ModelAccessEditorValue) {
onChange(nextValue);
}
function openAddProviderDialog() {
setProviderDialog({
mode: "add",
provider: createDefaultProvider(providers),
});
}
function openEditProviderDialog(provider: SetupProviderDraft) {
setProviderDialog({ mode: "edit", provider });
}
function saveProvider(provider: SetupProviderDraft) {
const previous = providerDialog?.provider ?? null;
const nextProviders =
providerDialog?.mode === "edit"
? providers.map((item) => (item.id === provider.id ? provider : item))
: [...providers, provider];
const nextModels =
previous && previous.name !== provider.name
? models.map((model) =>
model.providerName === previous.name
? { ...model, providerName: provider.name }
: model,
)
: models;
updateValue({
...value,
providers: nextProviders,
models: nextModels,
});
setProviderDialog(null);
}
function deleteProvider(provider: SetupProviderDraft) {
const nextProviders = providers.filter((item) => item.id !== provider.id);
const nextModels = models.filter(
(model) => model.providerName !== provider.name,
);
updateValue({
...value,
providers: nextProviders,
models: nextModels,
mainModel: safeSelectedModel(mainModel, nextModels),
efficientModel: safeSelectedModel(efficientModel, nextModels),
});
}
function openAddModelDialog() {
setModelDialog({
mode: "add",
model: createDefaultModel(providers),
});
}
function openEditModelDialog(model: SetupModelDraft) {
setModelDialog({ mode: "edit", model });
}
function saveModel(model: SetupModelDraft) {
const nextModels =
modelDialog?.mode === "edit"
? models.map((item) => (item.id === model.id ? model : item))
: [...models, model];
updateValue({
...value,
models: nextModels,
mainModel: safeSelectedModel(mainModel, nextModels),
efficientModel: safeSelectedModel(efficientModel, nextModels),
});
setModelDialog(null);
}
function deleteModel(model: SetupModelDraft) {
const nextModels = models.filter((item) => item.id !== model.id);
updateValue({
...value,
models: nextModels,
mainModel: safeSelectedModel(mainModel, nextModels),
efficientModel: safeSelectedModel(efficientModel, nextModels),
});
}
return (
<>
<RegistrySection
title={t("setup.modelAccess.providers")}
description={providerSectionDescription}
actionLabel={t("setup.modelAccess.addProvider")}
onAdd={openAddProviderDialog}
>
<ProviderList
providers={providers}
onEdit={openEditProviderDialog}
onDelete={deleteProvider}
/>
</RegistrySection>
<RegistrySection
title={t("setup.modelAccess.models")}
description={modelSectionDescription}
actionLabel={t("setup.modelAccess.addModel")}
onAdd={openAddModelDialog}
addDisabled={providers.length === 0}
>
<ModelList
models={models}
providers={providers}
onEdit={openEditModelDialog}
onDelete={deleteModel}
/>
</RegistrySection>
<section className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-medium tracking-normal">
{t("setup.modelAccess.selectModels")}
</h2>
<p className="max-w-2xl text-base text-muted-foreground">
{selectionSectionDescription}
</p>
</div>
<FieldGroup className={cn("max-w-2xl", fieldGroupClassName)}>
<div className="flex flex-col gap-4">
<Field data-invalid={mainModelMissing}>
<FieldLabel htmlFor="model-access-main-model">
{t("setup.modelAccess.mainModel")}
</FieldLabel>
<Select
value={mainModel}
onValueChange={(selected) =>
updateValue({ ...value, mainModel: selected })
}
>
<SelectTrigger id="model-access-main-model" className="w-full">
<SelectValue placeholder={t("setup.modelAccess.selectMainModel")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{modelNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError>
{mainModelMissing ? t("setup.modelAccess.selectModelError") : null}
</FieldError>
</Field>
<Field data-invalid={efficientModelMissing}>
<FieldLabel htmlFor="model-access-efficient-model">
{t("setup.modelAccess.efficientModel")}
</FieldLabel>
<Select
value={efficientModel}
onValueChange={(selected) =>
updateValue({ ...value, efficientModel: selected })
}
>
<SelectTrigger
id="model-access-efficient-model"
className="w-full"
>
<SelectValue
placeholder={t("setup.modelAccess.selectEfficientModel")}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{modelNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError>
{efficientModelMissing
? t("setup.modelAccess.selectModelError")
: null}
</FieldError>
</Field>
</div>
</FieldGroup>
{submitSlot}
</section>
<ProviderDialog
dialog={providerDialog}
providers={providers}
onOpenChange={(open) => {
if (!open) {
setProviderDialog(null);
}
}}
onSubmit={saveProvider}
/>
<ModelDialog
dialog={modelDialog}
models={models}
providers={providers}
onOpenChange={(open) => {
if (!open) {
setModelDialog(null);
}
}}
onSubmit={saveModel}
/>
</>
);
}
function RegistrySection({
actionLabel,
addDisabled = false,
children,
description,
onAdd,
title,
}: {
actionLabel: string;
addDisabled?: boolean;
children: ReactNode;
description?: string;
onAdd: () => void;
title: string;
}) {
return (
<section className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-medium tracking-normal">{title}</h2>
{description ? (
<p className="max-w-3xl text-base text-muted-foreground">
{description}
</p>
) : null}
</div>
<Button
type="button"
variant="outline"
size="icon"
className="size-12 rounded-full"
disabled={addDisabled}
aria-label={actionLabel}
onClick={onAdd}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
</Button>
</div>
{children}
</section>
);
}
function ProviderList({
onDelete,
onEdit,
providers,
}: {
onDelete: (provider: SetupProviderDraft) => void;
onEdit: (provider: SetupProviderDraft) => void;
providers: SetupProviderDraft[];
}) {
const { t } = useTranslation();
if (providers.length === 0) {
return (
<div className="border-y py-8 text-sm text-muted-foreground">
{t("setup.modelAccess.noProviders")}
</div>
);
}
return (
<div className="divide-y border-y">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between gap-6 py-4"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-lg font-medium">
{provider.name}
</span>
<Badge variant="secondary">
{providerKindLabel(provider.kind, t)}
</Badge>
{provider.kind === "openai_codex_oauth" ? (
<Badge variant="outline">
{codexAuthMethodLabel(provider.codexAuthMethod, t)}
</Badge>
) : null}
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{providerSummary(provider, t)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("setup.modelAccess.editProviderAria", {
name: provider.name,
})}
onClick={() => onEdit(provider)}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("setup.modelAccess.deleteProviderAria", {
name: provider.name,
})}
onClick={() => onDelete(provider)}
>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
);
}
function ModelList({
models,
onDelete,
onEdit,
providers,
}: {
models: SetupModelDraft[];
onDelete: (model: SetupModelDraft) => void;
onEdit: (model: SetupModelDraft) => void;
providers: SetupProviderDraft[];
}) {
const { t } = useTranslation();
if (models.length === 0) {
return (
<div className="border-y py-8 text-sm text-muted-foreground">
{t("setup.modelAccess.noModels")}
</div>
);
}
return (
<div className="divide-y border-y">
{models.map((model) => {
const provider = providers.find(
(item) => item.name === model.providerName,
);
return (
<div
key={model.id}
className="flex items-center justify-between gap-6 py-4"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-lg font-medium">
{model.name}
</span>
<Badge variant="secondary">{model.modelId}</Badge>
<Badge variant="outline">
{provider?.name ?? model.providerName}
</Badge>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{t("setup.modelAccess.modelSummary", {
context: model.contextWindowTokens || t("setup.modelAccess.auto"),
output: model.maxCompletionTokens || t("setup.modelAccess.auto"),
vision:
model.supportsVision === "auto"
? t("setup.modelAccess.visionAuto")
: model.supportsVision === "true"
? t("setup.modelAccess.visionYes")
: t("setup.modelAccess.visionNo"),
})}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("setup.modelAccess.editModelAria", {
name: model.name,
})}
onClick={() => onEdit(model)}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("setup.modelAccess.deleteModelAria", {
name: model.name,
})}
onClick={() => onDelete(model)}
>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
</Button>
</div>
</div>
);
})}
</div>
);
}
function ProviderDialog({
dialog,
onOpenChange,
onSubmit,
providers,
}: {
dialog: ProviderDialogState | null;
onOpenChange: (open: boolean) => void;
onSubmit: (provider: SetupProviderDraft) => void;
providers: SetupProviderDraft[];
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<SetupProviderDraft>(() =>
createDefaultProvider([]),
);
const [error, setError] = useState<string | null>(null);
const [authState, setAuthState] = useState<ProviderAuthState>("idle");
const [authMessage, setAuthMessage] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [authFileBrowserOpen, setAuthFileBrowserOpen] = useState(false);
const [deviceFlow, setDeviceFlow] =
useState<SetupProviderAuthStartResponse | null>(null);
const selectedKind = providerKindOption(draft.kind, t);
const selectedCodexMethod = codexAuthMethodOption(draft.codexAuthMethod, t);
const selectedGithubMethod = githubAuthMethodOption(draft.githubAuthMethod, t);
const needsApiKey =
draft.kind === "openai" ||
draft.kind === "openai_compatible" ||
draft.kind === "ollama_cloud";
const needsBaseUrl = draft.kind === "openai_compatible";
const showBaseUrl =
draft.kind === "openai" ||
draft.kind === "openai_compatible" ||
draft.kind === "openai_codex_oauth" ||
draft.kind === "ollama";
const showKeepAlive = draft.kind === "ollama" || draft.kind === "ollama_cloud";
const showCodexAuthFile =
draft.codexAuthMethod === "import_auth_file" ||
draft.codexAuthMethod === "existing_auth_file";
const usesDeviceAuth =
(draft.kind === "openai_codex_oauth" &&
draft.codexAuthMethod === "device_login") ||
(draft.kind === "github_copilot" &&
draft.githubAuthMethod === "device_login");
const usesProviderAuthAction =
(draft.kind === "openai_codex_oauth" &&
draft.codexAuthMethod !== "import_auth_file") ||
(draft.kind === "github_copilot" &&
draft.githubAuthMethod === "device_login");
const providerAuthButtonLabel = providerAuthActionLabel(draft, t);
const requiresCompletedProviderAuth =
providerRequiresCompletedAuthBeforeSave(draft);
const canSaveProvider =
authState !== "running" &&
(!requiresCompletedProviderAuth || authState === "success");
const duplicateName = providers.some(
(provider) =>
provider.id !== draft.id &&
provider.name.trim() === draft.name.trim() &&
draft.name.trim() !== "",
);
useEffect(() => {
if (!dialog?.provider) {
return;
}
setDraft(dialog.provider);
setError(null);
setAuthState("idle");
setAuthMessage(null);
setAuthError(null);
setDeviceFlow(null);
}, [dialog]);
function handleKindChange(value: SetupProviderKind) {
resetProviderAuthStatus();
setDraft((current) => {
const previousDefault = defaultProviderName(current.kind);
const nextDefault = uniqueProviderName(
defaultProviderName(value),
providers,
current.id,
);
const shouldReplaceName =
!current.name.trim() || current.name === previousDefault;
return {
...current,
kind: value,
name: shouldReplaceName ? nextDefault : current.name,
apiKey: defaultProviderApiKey(value, current.githubAuthMethod),
baseUrl: defaultProviderBaseUrl(value),
};
});
}
function handleProviderNameChange(value: string) {
if (value !== draft.name && draft.kind === "openai_codex_oauth") {
resetProviderAuthStatus();
}
setDraft((current) => ({
...current,
name: value,
}));
}
function handleGithubAuthMethodChange(value: GithubAuthMethod) {
resetProviderAuthStatus();
setDraft((current) => ({
...current,
githubAuthMethod: value,
apiKey: defaultGithubToken(value, current.apiKey),
}));
}
function handleCodexAuthMethodChange(value: CodexAuthMethod) {
resetProviderAuthStatus();
setDraft((current) => ({
...current,
codexAuthMethod: value,
}));
}
function handleCodexAuthFileChange(value: string) {
resetProviderAuthStatus();
setDraft((current) => ({
...current,
codexAuthFile: value,
}));
}
async function handleCodexAuthFileSelection(path: string) {
setAuthFileBrowserOpen(false);
resetProviderAuthStatus();
const provider = {
...draft,
codexAuthFile: path,
};
setDraft(provider);
if (!provider.name.trim()) {
setAuthState("error");
setAuthError(t("setup.modelAccess.providerDialog.errors.enterNameFirst"));
return;
}
setAuthState("running");
setAuthMessage(null);
setAuthError(null);
try {
const response = await runSetupProviderAuth(providerToRequest(provider));
applyProviderAuthResponse(response);
} catch (error) {
setAuthState("error");
setAuthError(error instanceof Error ? error.message : String(error));
}
}
function resetProviderAuthStatus() {
setAuthState("idle");
setAuthMessage(null);
setAuthError(null);
setDeviceFlow(null);
}
function applyProviderAuthResponse(response: {
api_key?: string | null;
auth_file?: string | null;
message: string;
}) {
setDraft((current) => ({
...current,
apiKey: response.api_key ?? current.apiKey,
codexAuthMethod:
current.kind === "openai_codex_oauth" && response.auth_file
? "existing_auth_file"
: current.codexAuthMethod,
codexAuthFile: response.auth_file ?? current.codexAuthFile,
}));
setAuthState("success");
setAuthMessage(response.message);
setAuthError(null);
}
async function handleRunProviderAuth() {
if (!usesProviderAuthAction) {
return;
}
if (!draft.name.trim()) {
setAuthState("error");
setAuthError(
t("setup.modelAccess.providerDialog.errors.enterNameFirst"),
);
return;
}
if (
draft.kind === "openai_codex_oauth" &&
showCodexAuthFile &&
!draft.codexAuthFile.trim()
) {
setAuthState("error");
setAuthError(
t("setup.modelAccess.providerDialog.errors.enterAuthFileFirst"),
);
return;
}
setAuthState("running");
setAuthMessage(null);
setAuthError(null);
try {
if (usesDeviceAuth) {
const flow = await startSetupProviderAuthDevice(providerToRequest(draft));
setDeviceFlow(flow);
setAuthState("device_pending");
setAuthMessage(
t("setup.modelAccess.providerDialog.deviceAuthOpened"),
);
return;
}
const response = await runSetupProviderAuth(providerToRequest(draft));
applyProviderAuthResponse(response);
} catch (error) {
setAuthState("error");
setAuthError(error instanceof Error ? error.message : String(error));
}
}
async function handleCompleteProviderAuth() {
if (!deviceFlow) {
return;
}
setAuthState("running");
setAuthError(null);
try {
const response = await completeSetupProviderAuthDevice(
providerToRequest(draft),
deviceFlow.flow_id,
);
applyProviderAuthResponse(response);
setDeviceFlow(null);
} catch (error) {
setAuthState("device_pending");
setAuthError(error instanceof Error ? error.message : String(error));
}
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!draft.name.trim()) {
setError(t("setup.modelAccess.providerDialog.errors.nameRequired"));
return;
}
if (duplicateName) {
setError(t("setup.modelAccess.providerDialog.errors.nameExists"));
return;
}
if (needsApiKey && !draft.apiKey.trim()) {
setError(t("setup.modelAccess.providerDialog.errors.apiKeyRequired"));
return;
}
if (draft.kind === "github_copilot" && !draft.apiKey.trim()) {
setError(
t("setup.modelAccess.providerDialog.errors.githubTokenRequired"),
);
return;
}
if (needsBaseUrl && !draft.baseUrl.trim()) {
setError(t("setup.modelAccess.providerDialog.errors.baseUrlRequired"));
return;
}
if (draft.kind === "openai_codex_oauth" && showCodexAuthFile) {
if (!draft.codexAuthFile.trim()) {
setError(t("setup.modelAccess.providerDialog.errors.authFileRequired"));
return;
}
}
if (requiresCompletedProviderAuth && authState !== "success") {
setError(providerAuthSaveBlockMessage(draft, t));
return;
}
onSubmit({
...draft,
name: draft.name.trim(),
apiKey: draft.apiKey.trim(),
baseUrl: draft.baseUrl.trim(),
keepAlive: draft.keepAlive.trim(),
codexAuthFile: draft.codexAuthFile.trim(),
});
}
return (
<>
<Dialog open={dialog !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{dialog?.mode === "edit"
? t("setup.modelAccess.providerDialog.editTitle")
: t("setup.modelAccess.providerDialog.addTitle")}
</DialogTitle>
<DialogDescription>
{t("setup.modelAccess.providerDialog.description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<FieldGroup>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor="setup-provider-name">
{t("setup.modelAccess.providerDialog.name")}
</FieldLabel>
<Input
id="setup-provider-name"
value={draft.name}
onChange={(event) => handleProviderNameChange(event.target.value)}
spellCheck={false}
required
/>
<FieldError>{error}</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="setup-provider-kind">
{t("setup.modelAccess.providerDialog.type")}
</FieldLabel>
<Select value={draft.kind} onValueChange={handleKindChange}>
<SelectTrigger id="setup-provider-kind" className="w-full">
<SelectValue
placeholder={t(
"setup.modelAccess.providerDialog.selectProviderType",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PROVIDER_KIND_ORDER.map((kind) => (
<SelectItem key={kind} value={kind}>
{providerKindLabel(kind, t)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>{selectedKind?.description}</FieldDescription>
</Field>
{draft.kind === "openai_codex_oauth" ? (
<>
<Field>
<FieldLabel htmlFor="setup-codex-auth-method">
{t(
"setup.modelAccess.providerDialog.codexAuthMethodLabel",
)}
</FieldLabel>
<Select
value={draft.codexAuthMethod}
onValueChange={(value) =>
handleCodexAuthMethodChange(value as CodexAuthMethod)
}
>
<SelectTrigger
id="setup-codex-auth-method"
className="w-full"
>
<SelectValue
placeholder={t(
"setup.modelAccess.providerDialog.selectAuthMethod",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{CODEX_AUTH_METHOD_ORDER.map((method) => (
<SelectItem key={method} value={method}>
{codexAuthMethodLabel(method, t)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
{selectedCodexMethod?.description}
</FieldDescription>
</Field>
{showCodexAuthFile ? (
<Field>
<FieldLabel htmlFor="setup-codex-auth-file">
{t("setup.modelAccess.providerDialog.authFilePath")}
</FieldLabel>
<div className="flex gap-2">
<Input
id="setup-codex-auth-file"
value={draft.codexAuthFile}
readOnly={draft.codexAuthMethod === "import_auth_file"}
onChange={(event) =>
handleCodexAuthFileChange(event.target.value)
}
placeholder="C:\\Users\\you\\.codex\\auth.json"
spellCheck={false}
/>
{draft.codexAuthMethod === "import_auth_file" ? (
<Button
type="button"
variant="outline"
disabled={authState === "running"}
onClick={() => setAuthFileBrowserOpen(true)}
>
{t("setup.modelAccess.providerDialog.chooseAuthFile")}
</Button>
) : null}
</div>
</Field>
) : null}
</>
) : null}
{draft.kind === "github_copilot" ? (
<>
<Field>
<FieldLabel htmlFor="setup-github-auth-method">
{t(
"setup.modelAccess.providerDialog.githubAuthMethodLabel",
)}
</FieldLabel>
<Select
value={draft.githubAuthMethod}
onValueChange={(value) =>
handleGithubAuthMethodChange(value as GithubAuthMethod)
}
>
<SelectTrigger
id="setup-github-auth-method"
className="w-full"
>
<SelectValue
placeholder={t(
"setup.modelAccess.providerDialog.selectAuthMethod",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{GITHUB_AUTH_METHOD_ORDER.map((method) => (
<SelectItem key={method} value={method}>
{githubAuthMethodLabel(method, t)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
{selectedGithubMethod?.description}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="setup-github-token">
{t("setup.modelAccess.providerDialog.githubToken")}
</FieldLabel>
<Input
id="setup-github-token"
value={draft.apiKey}
onChange={(event) =>
setDraft((current) => ({
...current,
apiKey: event.target.value,
}))
}
type={
draft.githubAuthMethod === "manual_token"
? "password"
: "text"
}
autoComplete="off"
spellCheck={false}
/>
</Field>
</>
) : null}
{usesProviderAuthAction ? (
<Field data-invalid={authState === "error"}>
<FieldLabel>
{t("setup.modelAccess.providerDialog.authentication")}
</FieldLabel>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
disabled={authState === "running"}
onClick={handleRunProviderAuth}
>
{authState === "running" ? (
<Spinner data-icon="inline-start" />
) : (
<ArrowRightIcon data-icon="inline-start" aria-hidden="true" />
)}
{deviceFlow
? t("setup.modelAccess.providerDialog.restart")
: providerAuthButtonLabel}
</Button>
{deviceFlow ? (
<Button
type="button"
disabled={authState === "running"}
onClick={handleCompleteProviderAuth}
>
{authState === "running" ? (
<Spinner data-icon="inline-start" />
) : (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
)}
{t(
"setup.modelAccess.providerDialog.completeAuthorization",
)}
</Button>
) : null}
</div>
{deviceFlow ? (
<div className="grid gap-1 text-sm">
<a
href={deviceFlow.verification_url}
target="_blank"
rel="noreferrer"
className="text-foreground underline underline-offset-4"
>
{deviceFlow.verification_url}
</a>
<div className="font-mono text-2xl tracking-wide">
{deviceFlow.user_code}
</div>
</div>
) : null}
{authError ? (
<FieldError>{authError}</FieldError>
) : (
<FieldDescription>
{authMessage ?? providerAuthDescription(draft, t)}
</FieldDescription>
)}
</Field>
) : null}
{needsApiKey ? (
<Field>
<FieldLabel htmlFor="setup-provider-api-key">
{t("setup.modelAccess.providerDialog.apiKey")}
</FieldLabel>
<Input
id="setup-provider-api-key"
value={draft.apiKey}
onChange={(event) =>
setDraft((current) => ({
...current,
apiKey: event.target.value,
}))
}
type="password"
autoComplete="off"
spellCheck={false}
/>
</Field>
) : null}
{showBaseUrl ? (
<Field data-invalid={needsBaseUrl && !draft.baseUrl.trim()}>
<FieldLabel htmlFor="setup-provider-base-url">
{draft.kind === "ollama"
? t("setup.modelAccess.providerDialog.host")
: t("setup.modelAccess.providerDialog.baseUrl")}
</FieldLabel>
<Input
id="setup-provider-base-url"
value={draft.baseUrl}
onChange={(event) =>
setDraft((current) => ({
...current,
baseUrl: event.target.value,
}))
}
placeholder={providerBaseUrlPlaceholder(draft.kind, t)}
aria-invalid={needsBaseUrl && !draft.baseUrl.trim()}
spellCheck={false}
/>
<FieldDescription>
{t("setup.modelAccess.providerDialog.baseUrlOptionalHint")}
</FieldDescription>
<FieldError>
{needsBaseUrl
? t("setup.modelAccess.providerDialog.baseUrlRequiredError")
: null}
</FieldError>
</Field>
) : null}
{showKeepAlive ? (
<Field>
<FieldLabel htmlFor="setup-provider-keep-alive">
{t("setup.modelAccess.providerDialog.keepAlive")}
</FieldLabel>
<Input
id="setup-provider-keep-alive"
value={draft.keepAlive}
onChange={(event) =>
setDraft((current) => ({
...current,
keepAlive: event.target.value,
}))
}
placeholder="5m"
spellCheck={false}
/>
</Field>
) : null}
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("setup.modelAccess.providerDialog.cancel")}
</Button>
<Button type="submit" disabled={!canSaveProvider}>
{t("setup.modelAccess.providerDialog.saveProvider")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DirBrowserDialog
open={authFileBrowserOpen}
mode="file"
onOpenChange={setAuthFileBrowserOpen}
onSelect={handleCodexAuthFileSelection}
/>
</>
);
}
function ModelDialog({
dialog,
models,
onOpenChange,
onSubmit,
providers,
}: {
dialog: ModelDialogState | null;
models: SetupModelDraft[];
onOpenChange: (open: boolean) => void;
onSubmit: (model: SetupModelDraft) => void;
providers: SetupProviderDraft[];
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<SetupModelDraft>(() =>
createDefaultModel([]),
);
const [error, setError] = useState<string | null>(null);
const [discoveredModels, setDiscoveredModels] = useState<
SetupDiscoveredModel[]
>([]);
const [discoveryState, setDiscoveryState] = useState<
"idle" | "loading" | "loaded" | "error"
>("idle");
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
const [discoveryRefreshKey, setDiscoveryRefreshKey] = useState(0);
const [thinkingSelection, setThinkingSelection] = useState<ThinkingSelection>(
THINKING_UNSET_VALUE,
);
const selectedProvider = providers.find(
(provider) => provider.name === draft.providerName,
);
const selectedProviderRequest = useMemo(
() => (selectedProvider ? providerToRequest(selectedProvider) : null),
[selectedProvider],
);
const selectedSuggestion = discoveredModels.some(
(model) => model.id === draft.modelId,
)
? draft.modelId
: "__manual__";
const thinkingOptions = useMemo(
() => modelThinkingOptions(draft.modelId, discoveredModels),
[discoveredModels, draft.modelId],
);
const duplicateName = models.some(
(model) =>
model.id !== draft.id &&
model.name.trim() === draft.name.trim() &&
draft.name.trim() !== "",
);
useEffect(() => {
if (!dialog?.model) {
return;
}
setDraft(dialog.model);
setThinkingSelection(
dialog.model.thinkingBudget.trim()
? THINKING_CUSTOM_VALUE
: THINKING_UNSET_VALUE,
);
setError(null);
}, [dialog]);
useEffect(() => {
const value = draft.thinkingBudget.trim();
if (!value) {
if (thinkingSelection !== THINKING_CUSTOM_VALUE) {
setThinkingSelection(THINKING_UNSET_VALUE);
}
return;
}
setThinkingSelection(
thinkingOptions.includes(value) ? value : THINKING_CUSTOM_VALUE,
);
}, [draft.thinkingBudget, thinkingOptions, thinkingSelection]);
useEffect(() => {
if (!dialog || !selectedProviderRequest) {
setDiscoveredModels([]);
setDiscoveryState("idle");
setDiscoveryError(null);
return;
}
const controller = new AbortController();
setDiscoveryState("loading");
setDiscoveryError(null);
void discoverSetupModels(selectedProviderRequest, {
signal: controller.signal,
})
.then((discovered) => {
setDiscoveredModels(discovered);
setDiscoveryState("loaded");
if (dialog.mode === "add" && discovered.length > 0) {
setDraft((current) => {
if (current.modelId.trim() !== "") {
return current;
}
if (current.providerName !== selectedProviderRequest.name) {
return current;
}
return applyDiscoveredModelToDraft(current, discovered[0], models);
});
}
})
.catch((error: unknown) => {
if (controller.signal.aborted) {
return;
}
setDiscoveredModels([]);
setDiscoveryState("error");
setDiscoveryError(error instanceof Error ? error.message : String(error));
});
return () => controller.abort();
}, [
dialog?.mode,
dialog?.model?.id,
selectedProviderRequest,
discoveryRefreshKey,
]);
function applySuggestion(modelId: string) {
const model = discoveredModels.find((item) => item.id === modelId);
if (!model) {
return;
}
setDraft((current) =>
applyDiscoveredModelToDraft(current, model, models),
);
}
function handleProviderChange(providerName: string) {
setThinkingSelection(THINKING_UNSET_VALUE);
setDraft((current) => ({
...current,
providerName,
...(dialog?.mode === "add"
? {
modelId: "",
name: "",
contextWindowTokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
maxCompletionTokens: DEFAULT_MAX_COMPLETION_TOKENS,
supportsVision: "auto" as const,
thinkingBudget: "",
apiStyle: "chat_completions" as const,
}
: {}),
}));
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!draft.providerName.trim()) {
setError(t("setup.modelAccess.modelDialog.errors.providerRequired"));
return;
}
if (!draft.name.trim()) {
setError(t("setup.modelAccess.modelDialog.errors.nameRequired"));
return;
}
if (duplicateName) {
setError(t("setup.modelAccess.modelDialog.errors.nameExists"));
return;
}
if (!draft.modelId.trim()) {
setError(t("setup.modelAccess.modelDialog.errors.modelIdRequired"));
return;
}
if (!parseOptionalPositiveInt(draft.contextWindowTokens)) {
setError(t("setup.modelAccess.modelDialog.errors.contextWindowInvalid"));
return;
}
if (!parseOptionalPositiveInt(draft.maxCompletionTokens)) {
setError(t("setup.modelAccess.modelDialog.errors.maxCompletionInvalid"));
return;
}
onSubmit({
...draft,
name: draft.name.trim(),
modelId: draft.modelId.trim(),
contextWindowTokens: draft.contextWindowTokens.trim(),
maxCompletionTokens: draft.maxCompletionTokens.trim(),
thinkingBudget: draft.thinkingBudget.trim(),
});
}
return (
<Dialog open={dialog !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{dialog?.mode === "edit"
? t("setup.modelAccess.modelDialog.editTitle")
: t("setup.modelAccess.modelDialog.addTitle")}
</DialogTitle>
<DialogDescription>
{t("setup.modelAccess.modelDialog.description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<FieldGroup>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor="setup-model-provider">
{t("setup.modelAccess.modelDialog.provider")}
</FieldLabel>
<Select
value={draft.providerName}
onValueChange={handleProviderChange}
>
<SelectTrigger id="setup-model-provider" className="w-full">
<SelectValue
placeholder={t(
"setup.modelAccess.modelDialog.selectProvider",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.name}>
{provider.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError>{error}</FieldError>
</Field>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="setup-model-discovered">
{t("setup.modelAccess.modelDialog.discoveredModels")}
</FieldLabel>
<Button
type="button"
variant="outline"
size="sm"
disabled={!selectedProvider || discoveryState === "loading"}
onClick={() => setDiscoveryRefreshKey((current) => current + 1)}
>
{discoveryState === "loading" ? (
<Spinner data-icon="inline-start" />
) : (
<RotateCcwIcon data-icon="inline-start" aria-hidden="true" />
)}
{t("setup.modelAccess.modelDialog.rediscover")}
</Button>
</div>
<Select
value={selectedSuggestion}
onValueChange={(value) => {
if (value !== "__manual__") {
applySuggestion(value);
}
}}
disabled={!selectedProvider}
>
<SelectTrigger id="setup-model-discovered" className="w-full">
<SelectValue
placeholder={t(
"setup.modelAccess.modelDialog.selectModelOrManual",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{discoveredModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
>
{model.id}
</SelectItem>
))}
<SelectItem value="__manual__">
{t("setup.modelAccess.modelDialog.manualInput")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{discoveryError ? (
<FieldError>{discoveryError}</FieldError>
) : (
<FieldDescription>
{modelDiscoveryDescription(
selectedProvider,
discoveryState,
discoveredModels.length,
t,
)}
</FieldDescription>
)}
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="setup-model-name">
{t("setup.modelAccess.modelDialog.modelName")}
</FieldLabel>
<Input
id="setup-model-name"
value={draft.name}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
spellCheck={false}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="setup-model-id">
{t("setup.modelAccess.modelDialog.modelId")}
</FieldLabel>
<Input
id="setup-model-id"
value={draft.modelId}
onChange={(event) =>
setDraft((current) => ({
...current,
modelId: event.target.value,
}))
}
spellCheck={false}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="setup-model-context">
{t("setup.modelAccess.modelDialog.contextWindowTokens")}
</FieldLabel>
<Input
id="setup-model-context"
value={draft.contextWindowTokens}
onChange={(event) =>
setDraft((current) => ({
...current,
contextWindowTokens: event.target.value,
}))
}
inputMode="numeric"
/>
</Field>
<Field>
<FieldLabel htmlFor="setup-model-output">
{t("setup.modelAccess.modelDialog.maxCompletionTokens")}
</FieldLabel>
<Input
id="setup-model-output"
value={draft.maxCompletionTokens}
onChange={(event) =>
setDraft((current) => ({
...current,
maxCompletionTokens: event.target.value,
}))
}
inputMode="numeric"
/>
</Field>
<Field>
<FieldLabel htmlFor="setup-model-vision">
{t("setup.modelAccess.modelDialog.vision")}
</FieldLabel>
<Select
value={draft.supportsVision}
onValueChange={(value) =>
setDraft((current) => ({
...current,
supportsVision: value as SupportsVisionValue,
}))
}
>
<SelectTrigger id="setup-model-vision" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="auto">
{t("setup.modelAccess.modelDialog.visionAuto")}
</SelectItem>
<SelectItem value="true">
{t("setup.modelAccess.modelDialog.visionSupported")}
</SelectItem>
<SelectItem value="false">
{t("setup.modelAccess.modelDialog.visionUnsupported")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{selectedProvider?.kind === "openai_compatible" ? (
<Field>
<FieldLabel htmlFor="setup-model-api-style">
{t("setup.modelAccess.modelDialog.apiStyle")}
</FieldLabel>
<Select
value={draft.apiStyle}
onValueChange={(value) =>
setDraft((current) => ({
...current,
apiStyle: value as ApiStyleValue,
}))
}
>
<SelectTrigger id="setup-model-api-style" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="chat_completions">
{t(
"setup.modelAccess.modelDialog.apiStyleChatCompletions",
)}
</SelectItem>
<SelectItem value="responses">
{t(
"setup.modelAccess.modelDialog.apiStyleResponses",
)}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
{t("setup.modelAccess.modelDialog.apiStyleDescription")}
</FieldDescription>
</Field>
) : null}
<Field>
<FieldLabel htmlFor="setup-model-thinking">
{t("setup.modelAccess.modelDialog.reasoning")}
</FieldLabel>
<Select
value={thinkingSelection}
onValueChange={(value) => {
if (value === THINKING_UNSET_VALUE) {
setThinkingSelection(value);
setDraft((current) => ({
...current,
thinkingBudget: "",
}));
return;
}
if (value === THINKING_CUSTOM_VALUE) {
setThinkingSelection(value);
setDraft((current) => ({
...current,
thinkingBudget: thinkingOptions.includes(
current.thinkingBudget.trim(),
)
? ""
: current.thinkingBudget,
}));
return;
}
setThinkingSelection(value);
setDraft((current) => ({
...current,
thinkingBudget: value,
}));
}}
>
<SelectTrigger id="setup-model-thinking" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={THINKING_UNSET_VALUE}>
{t("setup.modelAccess.modelDialog.notConfigured")}
</SelectItem>
{thinkingOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
<SelectItem value={THINKING_CUSTOM_VALUE}>
{t("setup.modelAccess.modelDialog.custom")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{thinkingSelection === THINKING_CUSTOM_VALUE ? (
<Input
value={draft.thinkingBudget}
onChange={(event) =>
setDraft((current) => ({
...current,
thinkingBudget: event.target.value,
}))
}
placeholder={t(
"setup.modelAccess.modelDialog.customReasoningPlaceholder",
)}
spellCheck={false}
/>
) : null}
</Field>
</div>
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("setup.modelAccess.modelDialog.cancel")}
</Button>
<Button type="submit">
{t("setup.modelAccess.modelDialog.saveModel")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function displayAgentName(personaName: string | null | undefined) {
return personaName?.trim() || "DaatLocus";
}
function normalizePersonaLanguage(personaLanguage: string | null | undefined) {
return personaLanguage?.trim() || "zh-CN";
}
const DEFAULT_PERSONA_IDENTITY_SUMMARY =
"{{name}} is a neutral, concise, results-oriented agent persona. It follows the configured locale for user-facing replies, communicates clearly, and prioritizes accurate, actionable responses.";
export function createDefaultAgentPersonalizationEditorValue(): AgentPersonalizationEditorValue {
return {
personaName: "DaatLocus",
personaLanguage: "zh-CN",
identitySummary: DEFAULT_PERSONA_IDENTITY_SUMMARY,
};
}
export function setupConfigRequestToAgentPersonalizationEditorValue(
request: SetupConfigRequest,
): AgentPersonalizationEditorValue {
return {
personaName: displayAgentName(request.persona_name),
personaLanguage: normalizePersonaLanguage(request.persona_language),
identitySummary:
request.persona_identity_summary?.trim() ||
DEFAULT_PERSONA_IDENTITY_SUMMARY,
};
}
export function agentPersonalizationEditorValueToSetupRequest(
value: AgentPersonalizationEditorValue,
): Pick<
SetupConfigRequest,
"persona_name" | "persona_language" | "persona_identity_summary"
> {
return {
persona_name: displayAgentName(value.personaName),
persona_language: normalizePersonaLanguage(value.personaLanguage),
persona_identity_summary:
value.identitySummary.trim() || DEFAULT_PERSONA_IDENTITY_SUMMARY,
};
}
export function createDefaultModelAccessEditorValue(): ModelAccessEditorValue {
return {
providers: [],
models: [],
mainModel: "",
efficientModel: "",
};
}
export function modelAccessEditorValueToSetupRequest(
value: ModelAccessEditorValue,
): Pick<
SetupConfigRequest,
"providers" | "models" | "main_model" | "efficient_model"
> {
return {
providers: value.providers.map(providerToRequest),
models: value.models.map(modelToRequest),
main_model: value.mainModel || null,
efficient_model: value.efficientModel || value.mainModel || null,
};
}
export function setupConfigRequestToModelAccessEditorValue(
request: SetupConfigRequest,
): ModelAccessEditorValue {
return {
providers: (request.providers ?? []).map(providerRequestToDraft),
models: (request.models ?? []).map(modelRequestToDraft),
mainModel: request.main_model ?? "",
efficientModel: request.efficient_model ?? request.main_model ?? "",
};
}
function providerRequestToDraft(provider: SetupProviderRequest): SetupProviderDraft {
const githubAuthMethod = normalizeGithubAuthMethod(provider);
return {
id: createLocalId("provider"),
name: provider.name,
kind: provider.kind,
apiKey:
provider.api_key ?? defaultProviderApiKey(provider.kind, githubAuthMethod),
baseUrl: provider.base_url ?? "",
keepAlive: provider.keep_alive ?? "",
codexAuthMethod: normalizeCodexAuthMethod(provider.codex_auth_method),
codexAuthFile: provider.codex_auth_file ?? "",
githubAuthMethod,
};
}
function modelRequestToDraft(model: SetupModelRequest): SetupModelDraft {
return {
id: createLocalId("model"),
name: model.name,
providerName: model.provider_name,
modelId: model.model_id,
contextWindowTokens: String(
model.context_window_tokens ?? DEFAULT_CONTEXT_WINDOW_TOKENS,
),
maxCompletionTokens: String(
model.max_completion_tokens ?? DEFAULT_MAX_COMPLETION_TOKENS,
),
supportsVision:
model.supports_vision == null
? "auto"
: model.supports_vision
? "true"
: "false",
thinkingBudget: model.thinking_budget ?? "",
apiStyle: model.api_style === "responses" ? "responses" : "chat_completions",
source: model,
};
}
function normalizeCodexAuthMethod(
value: string | null | undefined,
): CodexAuthMethod {
switch (value) {
case "browser_login":
case "device_login":
case "import_local_codex":
case "import_auth_file":
case "existing_auth_file":
return value;
default:
return "existing_auth_file";
}
}
function normalizeGithubAuthMethod(
provider: SetupProviderRequest,
): GithubAuthMethod {
switch (provider.github_auth_method) {
case "device_login":
case "manual_token":
case "env_token":
return provider.github_auth_method;
default:
return provider.api_key?.trim().startsWith("$")
? "env_token"
: "manual_token";
}
}
function createDefaultProvider(
providers: SetupProviderDraft[],
): SetupProviderDraft {
const kind: SetupProviderKind = "openai_codex_oauth";
return {
id: createLocalId("provider"),
name: uniqueProviderName(defaultProviderName(kind), providers),
kind,
apiKey: "",
baseUrl: "",
keepAlive: "",
codexAuthMethod: "import_local_codex",
codexAuthFile: "",
githubAuthMethod: "env_token",
};
}
function createDefaultModel(providers: SetupProviderDraft[]): SetupModelDraft {
const provider = providers[0];
return {
id: createLocalId("model"),
name: "",
providerName: provider?.name ?? "",
modelId: "",
contextWindowTokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
maxCompletionTokens: DEFAULT_MAX_COMPLETION_TOKENS,
supportsVision: "auto",
thinkingBudget: "",
apiStyle: "chat_completions",
};
}
function applyDiscoveredModelToDraft(
current: SetupModelDraft,
model: SetupDiscoveredModel,
models: SetupModelDraft[],
): SetupModelDraft {
return {
...current,
modelId: model.id,
name:
!current.name.trim() || current.name === defaultModelName(current.modelId)
? uniqueModelName(defaultModelName(model.id), models, current.id)
: current.name,
contextWindowTokens: String(
model.context_window_tokens ?? DEFAULT_CONTEXT_WINDOW_TOKENS,
),
maxCompletionTokens: String(
model.max_completion_tokens ?? DEFAULT_MAX_COMPLETION_TOKENS,
),
supportsVision:
model.supports_vision == null
? "auto"
: model.supports_vision
? "true"
: "false",
thinkingBudget:
defaultThinkingBudget(model.thinking_budgets ?? []) ??
current.thinkingBudget,
};
}
function providerToRequest(provider: SetupProviderDraft): SetupProviderRequest {
return {
kind: provider.kind,
name: provider.name,
api_key:
provider.kind === "openai_codex_oauth"
? null
: provider.apiKey.trim() || null,
base_url: provider.baseUrl.trim() || null,
keep_alive: provider.keepAlive.trim() || null,
codex_auth_method:
provider.kind === "openai_codex_oauth" ? provider.codexAuthMethod : null,
codex_auth_file:
provider.kind === "openai_codex_oauth"
? provider.codexAuthFile.trim() || null
: null,
github_auth_method:
provider.kind === "github_copilot" ? provider.githubAuthMethod : null,
};
}
function modelToRequest(model: SetupModelDraft): SetupModelRequest {
return {
...model.source,
name: model.name,
provider_name: model.providerName,
model_id: model.modelId,
context_window_tokens: parseOptionalPositiveInt(model.contextWindowTokens),
max_completion_tokens: parseOptionalPositiveInt(model.maxCompletionTokens),
supports_vision:
model.supportsVision === "auto" ? null : model.supportsVision === "true",
thinking_budget: model.thinkingBudget.trim() || null,
api_style: model.apiStyle === "responses" ? "responses" : null,
};
}
function providerKindLabel(kind: SetupProviderKind, t: TFunction) {
return t(`setup.modelAccess.providerKinds.${kind}.label`);
}
function modelDiscoveryDescription(
provider: SetupProviderDraft | undefined,
state: "idle" | "loading" | "loaded" | "error",
count: number,
t: TFunction,
) {
if (!provider) {
return t("setup.modelAccess.modelDialog.discovery.selectProviderFirst");
}
if (state === "loading") {
return t("setup.modelAccess.modelDialog.discovery.loading");
}
if (state === "loaded" && count > 0) {
return t("setup.modelAccess.modelDialog.discovery.loadedSome", { count });
}
if (state === "loaded") {
return t("setup.modelAccess.modelDialog.discovery.loadedNone");
}
return t("setup.modelAccess.modelDialog.discovery.idle");
}
function defaultThinkingBudget(values: string[]) {
if (values.includes("medium")) {
return "medium";
}
return values[0] ?? null;
}
function modelThinkingOptions(
modelId: string,
discoveredModels: SetupDiscoveredModel[],
) {
const model = discoveredModels.find((item) => item.id === modelId);
return uniqueStrings(
(model?.thinking_budgets ?? [])
.map((value) => value.trim())
.filter(Boolean),
);
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values));
}
function providerSummary(provider: SetupProviderDraft, t: TFunction) {
if (provider.kind === "openai_codex_oauth") {
return provider.baseUrl.trim()
? t("setup.modelAccess.providerDialog.summary.codexEndpoint", {
url: provider.baseUrl,
})
: t("setup.modelAccess.providerDialog.summary.codexDefaultEndpoint");
}
if (provider.kind === "github_copilot") {
return t("setup.modelAccess.providerDialog.summary.githubCopilot", {
method: githubAuthMethodLabel(provider.githubAuthMethod, t),
});
}
if (provider.kind === "ollama") {
return provider.baseUrl.trim() || "http://127.0.0.1:11434";
}
if (provider.kind === "ollama_cloud") {
return "https://ollama.com";
}
return (
provider.baseUrl.trim() ||
t("setup.modelAccess.providerDialog.summary.providerDefault")
);
}
function defaultProviderName(kind: SetupProviderKind) {
switch (kind) {
case "openai":
return "openai";
case "openai_codex_oauth":
return "codex-oauth";
case "github_copilot":
return "copilot";
case "openai_compatible":
return "openai-compatible";
case "ollama":
return "ollama";
case "ollama_cloud":
return "ollama-cloud";
}
}
function defaultProviderApiKey(
kind: SetupProviderKind,
githubAuthMethod: GithubAuthMethod,
) {
if (kind === "openai") {
return "$OPENAI_API_KEY";
}
if (kind === "github_copilot") {
return defaultGithubToken(githubAuthMethod, "");
}
if (kind === "ollama_cloud") {
return "$OLLAMA_API_KEY";
}
return "";
}
function defaultGithubToken(method: GithubAuthMethod, current: string) {
if (method === "env_token") {
return "$GITHUB_TOKEN";
}
return current === "$GITHUB_TOKEN" ? "" : current;
}
function defaultProviderBaseUrl(kind: SetupProviderKind) {
if (kind === "openai_compatible") {
return "https://api.openai.com/v1";
}
if (kind === "ollama") {
return "http://127.0.0.1:11434";
}
return "";
}
function providerBaseUrlPlaceholder(kind: SetupProviderKind, t: TFunction) {
if (kind === "ollama") {
return "http://127.0.0.1:11434";
}
if (kind === "openai_compatible") {
return "https://api.example.com/v1";
}
return t("setup.modelAccess.providerDialog.baseUrlPlaceholderDefault");
}
function codexAuthMethodLabel(method: CodexAuthMethod, t: TFunction) {
return t(`setup.modelAccess.codexAuthMethods.${method}.label`);
}
function githubAuthMethodLabel(method: GithubAuthMethod, t: TFunction) {
return t(`setup.modelAccess.githubAuthMethods.${method}.label`);
}
function providerAuthActionLabel(provider: SetupProviderDraft, t: TFunction) {
if (provider.kind === "github_copilot") {
return t("setup.modelAccess.providerDialog.authAction.github");
}
return t(
`setup.modelAccess.providerDialog.authAction.${provider.codexAuthMethod}`,
);
}
function providerAuthDescription(provider: SetupProviderDraft, t: TFunction) {
if (provider.kind === "github_copilot") {
return t("setup.modelAccess.providerDialog.authDescription.github");
}
return t(
`setup.modelAccess.providerDialog.authDescription.${provider.codexAuthMethod}`,
);
}
function providerRequiresCompletedAuthBeforeSave(provider: SetupProviderDraft) {
return (
provider.kind === "openai_codex_oauth" ||
(provider.kind === "github_copilot" &&
provider.githubAuthMethod === "device_login")
);
}
function providerAuthSaveBlockMessage(
provider: SetupProviderDraft,
t: TFunction,
) {
if (provider.kind === "github_copilot") {
return t("setup.modelAccess.providerDialog.authSaveBlock.github");
}
return t(
`setup.modelAccess.providerDialog.authSaveBlock.${provider.codexAuthMethod}`,
);
}
function defaultModelName(modelId: string) {
const name = modelId.split(/[/:]/).filter(Boolean).at(-1) ?? modelId;
return sanitizeName(name || "model");
}
function uniqueProviderName(
base: string,
providers: SetupProviderDraft[],
currentId?: string,
) {
const names = new Set(
providers
.filter((provider) => provider.id !== currentId)
.map((provider) => provider.name),
);
return uniqueName(base, names);
}
function uniqueModelName(
base: string,
models: SetupModelDraft[],
currentId?: string,
) {
const names = new Set(
models
.filter((model) => model.id !== currentId)
.map((model) => model.name),
);
return uniqueName(base, names);
}
function uniqueName(base: string, names: Set<string>) {
const sanitized = sanitizeName(base);
if (!names.has(sanitized)) {
return sanitized;
}
let index = 2;
while (names.has(`${sanitized}-${index}`)) {
index += 1;
}
return `${sanitized}-${index}`;
}
function sanitizeName(value: string) {
const sanitized = value
.trim()
.replace(/[^A-Za-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "");
return sanitized || "item";
}
function safeSelectedModel(current: string, models: SetupModelDraft[]) {
if (models.some((model) => model.name === current)) {
return current;
}
return models[0]?.name ?? "";
}
function parseOptionalPositiveInt(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
function createLocalId(prefix: string) {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
}