import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type {
AgentSkillCatalogEntry,
Bot,
ComputerMode,
Me,
ModelCatalogEntry,
ModelCredential,
ModelTier,
ModelTierMapping,
ThinkingLevel,
VoiceInfo,
} from "@rakazo/contracts";
import {
BOT_DESCRIPTION_MAX_LENGTH,
BOT_NAME_MAX_LENGTH,
BOT_TITLE_MAX_LENGTH,
MODEL_TIERS,
ModelTierSchema,
normalizeBotTags,
} from "@rakazo/contracts";
import {
Button,
Input,
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
Switch,
Textarea,
Toggle,
} from "@rakazo/ui-web";
import { X } from "lucide-react";
import { Suspense, useEffect, useId, useRef, useState } from "react";
import { connectedModelOptions } from "../../lib/connected-models";
import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { modelTierLabel } from "../../lib/model-tier-label";
import { rpc } from "../../lib/rpc";
import { AvatarStudioPopover } from "./avatar-studio-popover";
import { BotCapabilitiesFields } from "./bot-capabilities";
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;
tags?: string[];
restrictBuiltinTools?: boolean;
allowedBuiltinTools?: string[];
modelTier?: ModelTier | 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.modelTier
? tierOptionKey(bot.modelTier)
: bot.modelProvider && bot.modelId
? modelOptionKey(bot.modelProvider, bot.modelId)
: "",
);
const [thinkingLevel, setThinkingLevel] = useState(bot.thinkingLevel ?? "");
const [tags, setTags] = useState(bot.tags.join(", "));
const [restrictBuiltinTools, setRestrictBuiltinTools] = useState(bot.restrictBuiltinTools);
const [allowedBuiltinTools, setAllowedBuiltinTools] = useState(bot.allowedBuiltinTools);
const [toolNames, setToolNames] = useState(null);
const [credentials, setCredentials] = useState([]);
const [catalog, setCatalog] = useState([]);
const [tiers, setTiers] = 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 rpc.bots
.builtinTools({ botId: bot.id })
.then(setToolNames)
.catch(() => undefined);
void Promise.all([
rpc.models.credentials(),
rpc.models.list(),
rpc.me(),
rpc.models.tiers.list().catch(() => []),
])
.then(([nextCredentials, nextCatalog, nextMe, nextTiers]) => {
setCredentials(nextCredentials);
setCatalog(nextCatalog);
setMe(nextMe);
setTiers(nextTiers);
// Only mark ready on success — a failed catalog load must not clear
// an existing thinkingLevel override on save.
setModelMetaReady(true);
})
.catch(() => undefined);
}, []);
const connectedOptions = connectedModelOptions(credentials, catalog).map((option) => ({
...option,
key: modelOptionKey(option.provider, option.modelId),
}));
const selectedTier = parseTierOptionKey(modelKey);
const selectedTierModel = tiers.find((entry) => entry.tier === selectedTier);
// A mapped tier runs its model; an unmapped one falls back to the Space default.
const effectiveProvider = selectedTierModel
? selectedTierModel.provider
: modelKey && !selectedTier
? parseModelOptionKey(modelKey)?.provider
: (me?.defaultProvider ?? null);
const effectiveModelId = selectedTierModel
? selectedTierModel.modelId
: modelKey && !selectedTier
? parseModelOptionKey(modelKey)?.modelId
: (me?.defaultModel ?? null);
const effectiveEntry =
effectiveProvider && effectiveModelId
? catalog.find(
(entry) => entry.provider === effectiveProvider && entry.id === effectiveModelId,
)
: undefined;
// One connection serves every model of its tiers with the same reasoning settings.
const effectiveCredential = selectedTierModel
? credentials.find((entry) => entry.id === selectedTierModel.credentialId)
: credentials.find(
(entry) => entry.provider === effectiveProvider && entry.modelId === effectiveModelId,
);
const tierOptions = MODEL_TIERS.flatMap((tier) => {
const mapped = tiers.find((entry) => entry.tier === tier);
// Tiers appear once mapped; an unmapped current choice stays visible.
if (!mapped && tier !== selectedTier) return [];
const model = mapped
? (catalogLabel(catalog, mapped.provider, mapped.modelId) ?? mapped.modelId)
: null;
return [
{
key: tierOptionKey(tier),
label: model ? `${modelTierLabel(tier)} (${model})` : modelTierLabel(tier),
},
];
});
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 && !selectedTier ? 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;
const nextTags = normalizeBotTags(tags.split(","));
if (nextName) setName(nextName);
setTitle(nextTitle);
setDescription(nextDescription);
setTags(nextTags.join(", "));
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,
tags: nextTags,
restrictBuiltinTools,
// Names a newer release no longer ships would make the whole save fail validation.
allowedBuiltinTools: toolNames
? allowedBuiltinTools.filter((tool) => toolNames.includes(tool))
: allowedBuiltinTools,
modelTier: selectedTier,
...(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}`;
}
// Model keys always contain "::", so a tier key can never collide with one.
function tierOptionKey(tier: ModelTier) {
return `tier:${tier}`;
}
function parseTierOptionKey(key: string): ModelTier | null {
if (!key.startsWith("tier:")) return null;
const tier = ModelTierSchema.safeParse(key.slice("tier:".length));
return tier.success ? tier.data : null;
}
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;
}