The composer, the shell frame and sidebar, the right side panel and the create-bot modal were hard-coded dark while the text on them follows the theme, so in the light theme the typed draft, the sidebar rows and the side panel forms rendered dark on dark. - Composer: the island uses the card and foreground tokens; attach, stop and chips use border and foreground tokens, and send is the default primary button. The focus and shadow utilities on the bar are removed: .apple-island always overrode them, so they never rendered. - Shell frame and page background use the background token. The frame's radial gradient never rendered under Tailwind v4 and is dropped. - Sidebar: the panel uses the sidebar tokens on desktop too. The main conversation, roster, project and Heute rows share one token-based row style; muted text measures 4.4:1 on the light sidebar, so row times use a dimmed foreground instead. - Side panel and create-bot modal use background, popover and overlay tokens. Settings helper texts and inactive tabs use the full muted color, which stays above 4.5:1 in light. - Stage vignette, stage pill, ensemble and hero orb, and the transcript paths behind the call overlay use tokens. - E2E: chat-contrast.spec.ts gains a shell test that measures the composer draft, sidebar rows, the new bot form and the bot settings form in both themes and captures screenshots. Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ Co-authored-by: Claude <noreply@anthropic.com>
721 lines
24 KiB
TypeScript
721 lines
24 KiB
TypeScript
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 (
|
||
<div className="mt-4">
|
||
<div className="text-ui-body text-muted-foreground">
|
||
<Trans>Computer</Trans>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
{(["team", "dedicated"] as const).map((mode) => (
|
||
<Toggle
|
||
key={mode}
|
||
variant="outline"
|
||
pressed={value === mode}
|
||
data-testid={mode === "team" ? teamTestId : privateTestId}
|
||
onPressedChange={(pressed) => {
|
||
if (pressed) onChange(mode);
|
||
}}
|
||
className="capitalize aria-pressed:border-foreground/40 aria-pressed:text-foreground"
|
||
>
|
||
{mode === "team" ? <Trans>Team</Trans> : <Trans>Private</Trans>}
|
||
</Toggle>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CreateBotForm({
|
||
onCreate,
|
||
onCancel,
|
||
}: {
|
||
onCreate: (input: {
|
||
name: string;
|
||
title: string;
|
||
description: string;
|
||
computerMode: ComputerMode;
|
||
}) => Promise<void>;
|
||
onCancel: () => void;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const ids = useId();
|
||
const [name, setName] = useState("");
|
||
const [title, setTitle] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [computerMode, setComputerMode] = useState<ComputerMode>("team");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<div data-testid="create-bot-form">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<span className="text-ui-body text-muted-foreground">
|
||
<Trans>New bot</Trans>
|
||
</span>
|
||
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
|
||
<X strokeWidth={1.8} className="size-icon-md" />
|
||
</Button>
|
||
</div>
|
||
{error ? (
|
||
<p
|
||
role="alert"
|
||
data-testid="create-bot-error"
|
||
className="mb-3 text-ui-body-sm text-destructive"
|
||
>
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
<label htmlFor={`${ids}-name`} className="mt-6 block text-ui-body text-muted-foreground">
|
||
<Trans>Name</Trans>
|
||
<Input
|
||
id={`${ids}-name`}
|
||
value={name}
|
||
maxLength={BOT_NAME_MAX_LENGTH}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder={t`Name this bot`}
|
||
className="mt-2"
|
||
/>
|
||
</label>
|
||
<label htmlFor={`${ids}-title`} className={fieldLabelClass}>
|
||
<Trans>Title</Trans>
|
||
<Input
|
||
id={`${ids}-title`}
|
||
value={title}
|
||
maxLength={BOT_TITLE_MAX_LENGTH}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder={t`Describe what this bot does`}
|
||
className="mt-2"
|
||
/>
|
||
</label>
|
||
<label htmlFor={`${ids}-description`} className={fieldLabelClass}>
|
||
<Trans>Description</Trans>
|
||
<Textarea
|
||
id={`${ids}-description`}
|
||
value={description}
|
||
maxLength={BOT_DESCRIPTION_MAX_LENGTH}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder={t`What this bot is for`}
|
||
rows={4}
|
||
className="mt-2"
|
||
/>
|
||
</label>
|
||
<div data-testid="create-bot-computer">
|
||
<ComputerModePicker
|
||
value={computerMode}
|
||
onChange={setComputerMode}
|
||
teamTestId="create-bot-team"
|
||
privateTestId="create-bot-private"
|
||
/>
|
||
</div>
|
||
<Button
|
||
className="mt-5"
|
||
disabled={!name.trim() || submitting}
|
||
onClick={() => void handleSubmit()}
|
||
>
|
||
{submitting ? <Trans>Creating…</Trans> : <Trans>Create</Trans>}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<void>;
|
||
onExport: () => Promise<void>;
|
||
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<VoiceInfo[]>([]);
|
||
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<string[] | null>(null);
|
||
const [credentials, setCredentials] = useState<ModelCredential[]>([]);
|
||
const [catalog, setCatalog] = useState<ModelCatalogEntry[]>([]);
|
||
const [tiers, setTiers] = useState<ModelTierMapping[]>([]);
|
||
const [me, setMe] = useState<Me | null>(null);
|
||
const [modelMetaReady, setModelMetaReady] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const saveQueueRef = useRef(Promise.resolve());
|
||
const executeSaveRef = useRef<
|
||
(patchOverrides?: {
|
||
name?: string;
|
||
title?: string;
|
||
description?: string;
|
||
color?: string;
|
||
notifyOnFinish?: boolean;
|
||
}) => Promise<void>
|
||
>(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 (
|
||
<div data-testid="bot-settings">
|
||
<div className="flex justify-center py-4">
|
||
<AvatarStudioPopover
|
||
value={color}
|
||
identity={bot.id}
|
||
status={bot.status}
|
||
size={76}
|
||
onChange={(newColor) => {
|
||
setColor(newColor);
|
||
void enqueueSave({ color: newColor });
|
||
}}
|
||
/>
|
||
</div>
|
||
<label htmlFor={`${ids}-name`} className={fieldLabelClass}>
|
||
<Trans>Name</Trans>
|
||
<Input
|
||
id={`${ids}-name`}
|
||
value={name}
|
||
maxLength={BOT_NAME_MAX_LENGTH}
|
||
onChange={(e) => setName(e.target.value)}
|
||
onBlur={() => void enqueueSave()}
|
||
className="mt-1.5"
|
||
/>
|
||
</label>
|
||
<label htmlFor={`${ids}-title`} className={fieldLabelClass}>
|
||
<Trans>Title</Trans>
|
||
<Input
|
||
id={`${ids}-title`}
|
||
value={title}
|
||
maxLength={BOT_TITLE_MAX_LENGTH}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
onBlur={() => void enqueueSave()}
|
||
placeholder={t`e.g. Hivenet Agent, Presales, Timesheets bot`}
|
||
className="mt-1.5"
|
||
/>
|
||
</label>
|
||
<label htmlFor={`${ids}-description`} className={fieldLabelClass}>
|
||
<Trans>Description</Trans>
|
||
<Textarea
|
||
id={`${ids}-description`}
|
||
value={description}
|
||
maxLength={BOT_DESCRIPTION_MAX_LENGTH}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
onBlur={() => void enqueueSave()}
|
||
rows={3}
|
||
className="mt-1.5"
|
||
/>
|
||
</label>
|
||
<div className="mt-6 flex items-center justify-between pt-4 border-t border-border/20">
|
||
<div className="space-y-0.5 pe-4">
|
||
<div
|
||
id={`${ids}-notify-finish-label`}
|
||
className="text-ui-body font-medium text-foreground"
|
||
>
|
||
<Trans>Notifications</Trans>
|
||
</div>
|
||
<div id={`${ids}-notify-finish-desc`} className="text-ui-body-sm text-muted-foreground">
|
||
<Trans>Get notified when this Bot finishes or needs input</Trans>
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
id={`${ids}-notify-finish`}
|
||
checked={notifyOnFinish}
|
||
aria-labelledby={`${ids}-notify-finish-label`}
|
||
aria-describedby={`${ids}-notify-finish-desc`}
|
||
onCheckedChange={(checked) => {
|
||
setNotifyOnFinish(checked);
|
||
void enqueueSave({ notifyOnFinish: checked });
|
||
}}
|
||
/>
|
||
</div>
|
||
<details
|
||
data-testid="bot-settings-advanced"
|
||
className="group mt-5"
|
||
onToggle={(event) => {
|
||
if (event.currentTarget.open) setAdvancedOpened(true);
|
||
}}
|
||
>
|
||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-ui-body text-muted-foreground">
|
||
<span className="text-muted-foreground">
|
||
<Trans>Advanced</Trans>
|
||
</span>
|
||
<span aria-hidden="true" className="transition-transform group-open:rotate-90">
|
||
›
|
||
</span>
|
||
</summary>
|
||
<ComputerModePicker value={computerMode} onChange={setComputerMode} />
|
||
<Suspense fallback={null}>
|
||
<ScratchpadSection botId={bot.id} />
|
||
{advancedOpened ? (
|
||
<KnowledgeSection botId={bot.id} onSkillsChange={onSkillsChange} />
|
||
) : null}
|
||
</Suspense>
|
||
<label htmlFor={`${ids}-model`} className={fieldLabelClass}>
|
||
<Trans>Model</Trans>
|
||
<NativeSelect
|
||
id={`${ids}-model`}
|
||
className="mt-2 w-full"
|
||
value={modelKey}
|
||
onChange={(event) => {
|
||
setModelKey(event.target.value);
|
||
setThinkingLevel("");
|
||
}}
|
||
>
|
||
<NativeSelectOption value="">
|
||
{t`Space default`}
|
||
{me?.defaultModel
|
||
? ` (${catalogLabel(catalog, me.defaultProvider, me.defaultModel) ?? me.defaultModel})`
|
||
: ""}
|
||
</NativeSelectOption>
|
||
{tierOptions.length ? (
|
||
<NativeSelectOptGroup label={t`Tiers`}>
|
||
{tierOptions.map((option) => (
|
||
<NativeSelectOption key={option.key} value={option.key}>
|
||
{option.label}
|
||
</NativeSelectOption>
|
||
))}
|
||
</NativeSelectOptGroup>
|
||
) : null}
|
||
{modelKey &&
|
||
!selectedTier &&
|
||
!connectedOptions.some((option) => option.key === modelKey) ? (
|
||
<NativeSelectOption value={modelKey}>
|
||
{parseModelOptionKey(modelKey)?.modelId ?? modelKey}
|
||
</NativeSelectOption>
|
||
) : null}
|
||
{connectedOptions.map((option) => (
|
||
<NativeSelectOption key={option.key} value={option.key}>
|
||
{option.label}
|
||
</NativeSelectOption>
|
||
))}
|
||
</NativeSelect>
|
||
</label>
|
||
{thinkingOptions.length ? (
|
||
<label htmlFor={`${ids}-thinking`} className={fieldLabelClass}>
|
||
<Trans>Thinking</Trans>
|
||
<NativeSelect
|
||
id={`${ids}-thinking`}
|
||
className="mt-2 w-full"
|
||
value={thinkingLevel}
|
||
onChange={(event) => setThinkingLevel(event.target.value)}
|
||
>
|
||
<NativeSelectOption value="">
|
||
{t`Default (${thinkingLevelLabel(defaultThinkingLevel)})`}
|
||
</NativeSelectOption>
|
||
{thinkingOptions.map((level) => (
|
||
<NativeSelectOption key={level} value={level}>
|
||
{thinkingLevelLabel(level)}
|
||
</NativeSelectOption>
|
||
))}
|
||
</NativeSelect>
|
||
</label>
|
||
) : null}
|
||
{memoryProviderConfigured ? (
|
||
<div className="mt-4 text-ui-body text-muted-foreground">
|
||
<Trans>Memory scope</Trans>
|
||
<div className="mt-2 flex gap-2">
|
||
{(
|
||
[
|
||
{ 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) => (
|
||
<Toggle
|
||
key={option.label}
|
||
variant="outline"
|
||
size="sm"
|
||
pressed={memoryScope === option.value}
|
||
onPressedChange={(pressed) => {
|
||
if (pressed) setMemoryScope(option.value);
|
||
}}
|
||
className="flex-1 aria-pressed:border-foreground/40 aria-pressed:text-foreground"
|
||
>
|
||
{option.label}
|
||
</Toggle>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<label
|
||
htmlFor={`${ids}-auto-speak`}
|
||
className="mt-5 flex cursor-pointer items-center gap-3 text-ui-body text-foreground/75"
|
||
>
|
||
<Switch
|
||
id={`${ids}-auto-speak`}
|
||
checked={autoSpeak}
|
||
onCheckedChange={(checked) => setAutoSpeak(checked)}
|
||
/>
|
||
<Trans>Read replies aloud</Trans>
|
||
</label>
|
||
{voices.length ? (
|
||
<label htmlFor={`${ids}-voice`} className={fieldLabelClass}>
|
||
<Trans>Voice</Trans>
|
||
<NativeSelect
|
||
id={`${ids}-voice`}
|
||
className="mt-2 w-full"
|
||
value={voiceId}
|
||
onChange={(event) => setVoiceId(event.target.value)}
|
||
>
|
||
<NativeSelectOption value="">{t`Account default`}</NativeSelectOption>
|
||
{voices.map((voice) => (
|
||
<NativeSelectOption key={voice.id} value={voice.id}>
|
||
{voice.label}
|
||
</NativeSelectOption>
|
||
))}
|
||
</NativeSelect>
|
||
</label>
|
||
) : null}
|
||
<BotCapabilitiesFields
|
||
tags={tags}
|
||
onTagsChange={setTags}
|
||
restrictBuiltinTools={restrictBuiltinTools}
|
||
onRestrictBuiltinToolsChange={setRestrictBuiltinTools}
|
||
allowedBuiltinTools={allowedBuiltinTools}
|
||
onAllowedBuiltinToolsChange={setAllowedBuiltinTools}
|
||
toolNames={toolNames ?? []}
|
||
/>
|
||
</details>
|
||
{error ? <p className="mt-2 text-ui-body-sm text-destructive">{error}</p> : null}
|
||
<div className="mt-5 flex flex-col items-start gap-3">
|
||
<Button
|
||
disabled={saving}
|
||
onClick={() => {
|
||
void enqueueSave({
|
||
name,
|
||
title,
|
||
description,
|
||
color,
|
||
notifyOnFinish,
|
||
});
|
||
}}
|
||
>
|
||
<Trans>Save</Trans>
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="-ms-2.5" onClick={() => void onExport()}>
|
||
<Trans>Export</Trans>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="-ms-2.5 text-destructive hover:text-destructive"
|
||
onClick={onClear}
|
||
>
|
||
<Trans>Clear conversation</Trans>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|