import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type {
AgentSkillCatalogEntry,
Bot,
ComputerMode,
Me,
ModelCatalogEntry,
ModelCredential,
ThinkingLevel,
VoiceInfo,
} from "@rakazo/contracts";
import {
BOT_DESCRIPTION_MAX_LENGTH,
BOT_NAME_MAX_LENGTH,
BOT_TITLE_MAX_LENGTH,
} from "@rakazo/contracts";
import {
Button,
Input,
NativeSelect,
NativeSelectOption,
Switch,
Textarea,
Toggle,
} from "@rakazo/ui-web";
import { X } from "lucide-react";
import { Suspense, useEffect, useId, useRef, useState } from "react";
import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { rpc } from "../../lib/rpc";
import { AvatarStudioPopover } from "./avatar-studio-popover";
const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
);
const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () =>
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
);
const fieldLabelClass = "mt-4 block text-ui-body text-muted-foreground";
function ComputerModePicker({
value,
onChange,
teamTestId,
privateTestId,
}: {
value: ComputerMode;
onChange: (value: ComputerMode) => void;
teamTestId?: string;
privateTestId?: string;
}) {
return (
Computer
{(["team", "dedicated"] as const).map((mode) => (
{
if (pressed) onChange(mode);
}}
className="capitalize aria-pressed:border-foreground/40 aria-pressed:text-foreground"
>
{mode === "team" ? Team : Private}
))}
);
}
export function CreateBotForm({
onCreate,
onCancel,
}: {
onCreate: (input: {
name: string;
title: string;
description: string;
computerMode: ComputerMode;
}) => Promise;
onCancel: () => void;
}) {
const { t } = useLingui();
const ids = useId();
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [computerMode, setComputerMode] = useState("team");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit() {
if (!name.trim() || submitting) return;
setError(null);
setSubmitting(true);
try {
await onCreate({
name: name.trim(),
title: title.trim(),
description: description.trim(),
computerMode,
});
} catch (err) {
setError(err instanceof Error ? err.message : t`Could not create bot`);
} finally {
setSubmitting(false);
}
}
return (
);
}
export function BotSettings({
bot,
memoryProviderConfigured,
onSkillsChange,
onSave,
onExport,
onClear,
}: {
bot: Bot;
onSkillsChange: (skills: AgentSkillCatalogEntry[]) => void;
memoryProviderConfigured: boolean;
onSave: (patch: {
name?: string;
title?: string;
description?: string;
instructions?: string;
color?: string;
notifyOnFinish?: boolean;
computerMode: ComputerMode;
memoryScope?: "isolated" | "shared" | null;
autoSpeak?: boolean;
voiceId?: string | null;
modelProvider?: string | null;
modelId?: string | null;
thinkingLevel?: ThinkingLevel | null;
}) => Promise;
onExport: () => Promise;
onClear: () => void;
}) {
const { t } = useLingui();
const [advancedOpened, setAdvancedOpened] = useState(false);
const ids = useId();
const [name, setName] = useState(bot.name);
const [title, setTitle] = useState(bot.title);
const [description, setDescription] = useState(bot.description);
const [color, setColor] = useState(bot.color);
const [notifyOnFinish, setNotifyOnFinish] = useState(bot.notifyOnFinish ?? true);
const [computerMode, setComputerMode] = useState(bot.computerMode);
const [memoryScope, setMemoryScope] = useState(bot.memoryScope);
const [autoSpeak, setAutoSpeak] = useState(bot.autoSpeak);
const [voiceId, setVoiceId] = useState(bot.voiceId ?? "");
const [voices, setVoices] = useState([]);
const [modelKey, setModelKey] = useState(
bot.modelProvider && bot.modelId ? modelOptionKey(bot.modelProvider, bot.modelId) : "",
);
const [thinkingLevel, setThinkingLevel] = useState(bot.thinkingLevel ?? "");
const [credentials, setCredentials] = useState([]);
const [catalog, setCatalog] = useState([]);
const [me, setMe] = useState(null);
const [modelMetaReady, setModelMetaReady] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const saveQueueRef = useRef(Promise.resolve());
const executeSaveRef = useRef<
(patchOverrides?: {
name?: string;
title?: string;
description?: string;
color?: string;
notifyOnFinish?: boolean;
}) => Promise
>(async () => undefined);
useEffect(() => {
void rpc.voice
.voices({})
.then(setVoices)
.catch(() => setVoices([]));
void Promise.all([rpc.models.credentials(), rpc.models.list(), rpc.me()])
.then(([nextCredentials, nextCatalog, nextMe]) => {
setCredentials(nextCredentials);
setCatalog(nextCatalog);
setMe(nextMe);
// Only mark ready on success — a failed catalog load must not clear
// an existing thinkingLevel override on save.
setModelMetaReady(true);
})
.catch(() => undefined);
}, []);
const connectedOptions: Array<{
key: string;
provider: string;
modelId: string;
label: string;
}> = [];
const seenOptions = new Set();
for (const credential of credentials) {
const providerModels = catalog.filter(
(entry) => entry.provider === credential.provider && !entry.placeholder,
);
const credentialInCatalog = Boolean(
credential.modelId && providerModels.some((entry) => entry.id === credential.modelId),
);
// Catalog providers expand to every model for that connection. Free-form
// credentials (model id not in the catalog) stay a single connected pair.
const options =
credential.modelId && !credentialInCatalog
? [
{
key: modelOptionKey(credential.provider, credential.modelId),
provider: credential.provider,
modelId: credential.modelId,
label: `${credential.label} · ${credential.modelId}`,
},
]
: providerModels.map((entry) => ({
key: modelOptionKey(entry.provider, entry.id),
provider: entry.provider,
modelId: entry.id,
label: `${entry.providerName ?? entry.provider} · ${entry.label}`,
}));
for (const option of options) {
if (seenOptions.has(option.key)) continue;
seenOptions.add(option.key);
connectedOptions.push(option);
}
}
const effectiveProvider = modelKey
? parseModelOptionKey(modelKey)?.provider
: (me?.defaultProvider ?? null);
const effectiveModelId = modelKey
? parseModelOptionKey(modelKey)?.modelId
: (me?.defaultModel ?? null);
const effectiveEntry =
effectiveProvider && effectiveModelId
? catalog.find(
(entry) => entry.provider === effectiveProvider && entry.id === effectiveModelId,
)
: undefined;
const effectiveCredential = credentials.find(
(entry) => entry.provider === effectiveProvider && entry.modelId === effectiveModelId,
);
const thinkingOptions = (
effectiveCredential?.thinkingLevels ??
effectiveEntry?.thinkingLevels ??
[]
).filter((level) => level !== "off");
const defaultThinkingLevel = effectiveCredential?.thinkingLevel ?? "medium";
async function executeSave(patchOverrides?: {
name?: string;
title?: string;
description?: string;
color?: string;
notifyOnFinish?: boolean;
}) {
const selected = modelKey ? parseModelOptionKey(modelKey) : null;
const nextName = (patchOverrides?.name !== undefined ? patchOverrides.name : name).trim();
const nextTitle = (patchOverrides?.title !== undefined ? patchOverrides.title : title).trim();
const nextDescription = (
patchOverrides?.description !== undefined ? patchOverrides.description : description
).trim();
const nextColor = patchOverrides?.color !== undefined ? patchOverrides.color : color;
const nextNotify =
patchOverrides?.notifyOnFinish !== undefined ? patchOverrides.notifyOnFinish : notifyOnFinish;
if (nextName) setName(nextName);
setTitle(nextTitle);
setDescription(nextDescription);
try {
setSaving(true);
setError(null);
await onSave({
name: nextName || bot.name,
title: nextTitle,
description: nextDescription,
instructions: nextDescription,
color: nextColor,
notifyOnFinish: nextNotify,
computerMode,
memoryScope,
autoSpeak,
voiceId: voiceId || null,
modelProvider: selected?.provider ?? null,
modelId: selected?.modelId ?? null,
...(modelMetaReady
? {
thinkingLevel: thinkingOptions.length
? ((thinkingLevel || null) as ThinkingLevel | null)
: null,
}
: {}),
});
} catch (err) {
setError(err instanceof Error ? err.message : t`Could not save`);
} finally {
setSaving(false);
}
}
executeSaveRef.current = executeSave;
function enqueueSave(patchOverrides?: {
name?: string;
title?: string;
description?: string;
color?: string;
notifyOnFinish?: boolean;
}) {
// Serialize full-object auto-saves so an older in-flight request cannot
// finish after a newer one and clobber fields. Always call through a ref so
// queued work reads the latest field values, not a stale render closure.
saveQueueRef.current = saveQueueRef.current
.catch(() => undefined)
.then(() => executeSaveRef.current(patchOverrides));
return saveQueueRef.current;
}
return (
{
setColor(newColor);
void enqueueSave({ color: newColor });
}}
/>
Notifications
Get notified when this Bot finishes or needs input
{
setNotifyOnFinish(checked);
void enqueueSave({ notifyOnFinish: checked });
}}
/>
{
if (event.currentTarget.open) setAdvancedOpened(true);
}}
>
Advanced
›
{advancedOpened ? (
) : null}
{thinkingOptions.length ? (
) : null}
{memoryProviderConfigured ? (
Memory scope
{(
[
{ value: null, label: t`Inherit default` },
{ value: "isolated" as const, label: t`Isolated` },
{ value: "shared" as const, label: t`Shared` },
] satisfies Array<{ value: "isolated" | "shared" | null; label: string }>
).map((option) => (
{
if (pressed) setMemoryScope(option.value);
}}
className="flex-1 aria-pressed:border-foreground/40 aria-pressed:text-foreground"
>
{option.label}
))}
) : null}
{voices.length ? (
) : null}
{error ?
{error}
: null}
);
}
function modelOptionKey(provider: string, modelId: string) {
return `${provider}::${modelId}`;
}
function thinkingLevelLabel(level: ThinkingLevel) {
if (level === "xhigh") return t`Extra high`;
if (level === "low") return t`Low`;
if (level === "medium") return t`Medium`;
if (level === "high") return t`High`;
if (level === "minimal") return t`Minimal`;
if (level === "max") return t`Max`;
return `${level.slice(0, 1).toUpperCase()}${level.slice(1)}`;
}
function parseModelOptionKey(key: string) {
const separator = key.indexOf("::");
if (separator <= 0) return null;
return { provider: key.slice(0, separator), modelId: key.slice(separator + 2) };
}
function catalogLabel(
catalog: ModelCatalogEntry[],
provider: string | null | undefined,
modelId: string,
) {
if (!provider) return undefined;
return catalog.find((entry) => entry.provider === provider && entry.id === modelId)?.label;
}