evOo-local/apps/mobile/app/bot-settings.tsx
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

510 lines
17 KiB
TypeScript

import {
BOT_COLORS,
BOT_DESCRIPTION_MAX_LENGTH,
BOT_NAME_MAX_LENGTH,
BOT_TITLE_MAX_LENGTH,
type ComputerMode,
normalizeCreateBotProfile,
type ThinkingLevel,
} from "@rakazo/contracts";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { BotAvatar } from "../components/bot-avatar";
import { ComputerModePicker } from "../components/computer-mode-picker";
import {
type MobileBot,
type MobileMe,
type MobileModel,
type MobileModelCredential,
rpc,
} from "../lib/api";
import { COMPUTER_LIFECYCLE_TIMEOUT_MS } from "../lib/computer";
import { useI18n } from "../lib/i18n";
import { presentMessageActionSheet } from "../lib/message-action-sheet";
import { useMobileTokens, useResolvedAppearance } from "../lib/native";
type BotSettingsRecord = MobileBot & {
description?: string;
};
type ModelOption = {
key: string;
provider: string;
modelId: string;
label: string;
};
type PickerChoice = {
key: string;
label: string;
};
export default function BotSettingsScreen() {
const tokens = useMobileTokens();
const colorScheme = useResolvedAppearance();
const { t } = useI18n();
const router = useRouter();
const { botId } = useLocalSearchParams<{ botId: string }>();
const [bot, setBot] = useState<BotSettingsRecord | null>(null);
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState<string>(BOT_COLORS[0]);
const [computerMode, setComputerMode] = useState<ComputerMode>("team");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [modelKey, setModelKey] = useState("");
const [thinkingLevel, setThinkingLevel] = useState("");
const [credentials, setCredentials] = useState<MobileModelCredential[]>([]);
const [catalog, setCatalog] = useState<MobileModel[]>([]);
const [me, setMe] = useState<MobileMe | null>(null);
const [modelMetaReady, setModelMetaReady] = useState(false);
const [modelMetaError, setModelMetaError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
if (!botId) return;
void rpc<BotSettingsRecord>("bots/get", { botId })
.then((next) => {
setBot(next);
setName(next.name);
setTitle(next.title);
setDescription(next.description ?? "");
setColor(next.color);
setComputerMode(next.computerMode);
setModelKey(
next.modelProvider && next.modelId
? modelOptionKey(next.modelProvider, next.modelId)
: "",
);
setThinkingLevel(next.thinkingLevel ?? "");
})
.catch((err) => setError(err instanceof Error ? err.message : t("Could not load bot")));
}, [botId]);
useEffect(() => {
void Promise.all([
rpc<MobileMe>("me"),
rpc<MobileModel[]>("models/list"),
rpc<MobileModelCredential[]>("models/credentials"),
])
.then(([nextMe, nextCatalog, nextCredentials]) => {
setMe(nextMe);
setCatalog(nextCatalog);
setCredentials(nextCredentials);
setModelMetaError(null);
setModelMetaReady(true);
})
.catch((err) => {
setModelMetaReady(false);
setModelMetaError(err instanceof Error ? err.message : t("Could not load model settings"));
});
}, [t]);
const connectedOptions = useMemo(() => {
const options: ModelOption[] = [];
const seen = new Set<string>();
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),
);
const nextOptions =
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 nextOptions) {
if (seen.has(option.key)) continue;
seen.add(option.key);
options.push(option);
}
}
return options;
}, [catalog, credentials]);
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 spaceDefaultLabel = me?.defaultModel
? `${t("Space default")} (${catalogLabel(catalog, me.defaultProvider, me.defaultModel) ?? me.defaultModel})`
: t("Space default");
const modelChoices: PickerChoice[] = useMemo(() => {
const choices: PickerChoice[] = [{ key: "", label: spaceDefaultLabel }];
if (modelKey && !connectedOptions.some((option) => option.key === modelKey)) {
choices.push({
key: modelKey,
label: parseModelOptionKey(modelKey)?.modelId ?? modelKey,
});
}
for (const option of connectedOptions) {
choices.push({ key: option.key, label: option.label });
}
return choices;
}, [connectedOptions, modelKey, spaceDefaultLabel]);
const thinkingChoices: PickerChoice[] = useMemo(
() => [
{ key: "", label: t("Default (medium)") },
...thinkingOptions.map((level) => ({
key: level,
label: thinkingLevelLabel(level, t),
})),
],
[t, thinkingOptions],
);
const selectedModelLabel =
modelChoices.find((choice) => choice.key === modelKey)?.label ?? spaceDefaultLabel;
const selectedThinkingLabel =
thinkingChoices.find((choice) => choice.key === thinkingLevel)?.label ?? t("Default (medium)");
function selectModel(key: string) {
if (key === modelKey) return;
setModelKey(key);
setThinkingLevel("");
}
function openModelPicker() {
presentMessageActionSheet({
title: t("Model"),
actions: modelChoices.map((choice) => ({
text: choice.label,
onPress: () => selectModel(choice.key),
})),
colorScheme,
cancel: t("Cancel"),
more: t("More"),
});
}
function openThinkingPicker() {
presentMessageActionSheet({
title: t("Thinking"),
actions: thinkingChoices.map((choice) => ({
text: choice.label,
onPress: () => setThinkingLevel(choice.key),
})),
colorScheme,
cancel: t("Cancel"),
more: t("More"),
});
}
async function save() {
if (!botId || !bot || pending) return;
setPending(true);
setError(null);
try {
const profile = normalizeCreateBotProfile({ name, title, description });
const selected = modelKey ? parseModelOptionKey(modelKey) : null;
const input: {
botId: string;
name?: string;
title?: string;
description?: string;
instructions?: string;
color?: string;
modelProvider?: string | null;
modelId?: string | null;
thinkingLevel?: ThinkingLevel | null;
} = { botId };
if (profile.name !== bot.name) input.name = profile.name;
if (profile.title !== bot.title) input.title = profile.title;
if (profile.description !== (bot.description ?? "")) {
input.description = profile.description;
// Keep instructions in sync with description (same as web BotSettings).
input.instructions = profile.instructions;
}
if (color !== bot.color) input.color = color;
const modelChanged =
(selected?.provider ?? null) !== (bot.modelProvider ?? null) ||
(selected?.modelId ?? null) !== (bot.modelId ?? null);
const thinkingChanged = (thinkingLevel || null) !== (bot.thinkingLevel ?? null);
if (modelChanged) {
input.modelProvider = selected?.provider ?? null;
input.modelId = selected?.modelId ?? null;
}
if (modelMetaReady && (modelChanged || thinkingChanged)) {
input.thinkingLevel = thinkingOptions.length
? ((thinkingLevel || null) as ThinkingLevel | null)
: null;
}
if (computerMode !== bot.computerMode) {
await rpc(
"bots/setComputer",
{ botId, mode: computerMode },
{ timeoutMs: COMPUTER_LIFECYCLE_TIMEOUT_MS },
);
}
// Use key presence so clearing title/description to "" still persists.
if (Object.keys(input).length > 1) {
await rpc("bots/update", input);
}
router.back();
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not save bot"));
} finally {
setPending(false);
}
}
return (
<>
<Stack.Screen options={{ title: t("Chat settings") }} />
<ScrollView
style={{ flex: 1, backgroundColor: tokens.background }}
contentContainerStyle={{ padding: 24 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{bot ? (
<View style={{ alignItems: "center", marginBottom: 24 }}>
<BotAvatar color={color} identity={bot.id} size={64} status={bot.status} />
</View>
) : null}
<Text style={{ color: tokens.mutedForeground, fontSize: 14 }}>{t("Name")}</Text>
<TextInput
value={name}
maxLength={BOT_NAME_MAX_LENGTH}
onChangeText={setName}
placeholder={t("Name this bot")}
placeholderTextColor={tokens.mutedForeground}
style={{
marginTop: 8,
backgroundColor: tokens.muted,
borderRadius: 11,
padding: 16,
color: tokens.foreground,
}}
/>
<Text style={{ color: tokens.mutedForeground, marginTop: 16, fontSize: 14 }}>
{t("Title")}
</Text>
<TextInput
value={title}
maxLength={BOT_TITLE_MAX_LENGTH}
onChangeText={setTitle}
placeholder={t("Describe what this bot does")}
placeholderTextColor={tokens.mutedForeground}
style={{
marginTop: 8,
backgroundColor: tokens.muted,
borderRadius: 11,
padding: 16,
color: tokens.foreground,
}}
/>
<Text style={{ color: tokens.mutedForeground, marginTop: 16, fontSize: 14 }}>
{t("Description")}
</Text>
<TextInput
value={description}
maxLength={BOT_DESCRIPTION_MAX_LENGTH}
onChangeText={setDescription}
placeholder={t("What this bot is for")}
placeholderTextColor={tokens.mutedForeground}
multiline
style={{
marginTop: 8,
backgroundColor: tokens.muted,
borderRadius: 11,
padding: 16,
color: tokens.foreground,
minHeight: 120,
textAlignVertical: "top",
}}
/>
<Text style={{ color: tokens.mutedForeground, marginTop: 16, fontSize: 14 }}>
{t("Color")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 10, marginTop: 8 }}
accessibilityRole="radiogroup"
>
{BOT_COLORS.map((option, index) => (
<Pressable
key={option}
accessibilityRole="radio"
accessibilityLabel={t("Color {number}", { number: index + 1 })}
accessibilityState={{ checked: color === option }}
onPress={() => setColor(option)}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: option,
borderWidth: 3,
borderColor: color === option ? tokens.foreground : "transparent",
}}
/>
))}
</ScrollView>
<ComputerModePicker value={computerMode} onChange={setComputerMode} />
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Advanced")}
accessibilityState={{ expanded: advancedOpen }}
onPress={() => setAdvancedOpen((open) => !open)}
style={{
marginTop: 20,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 14 }}>{t("Advanced")}</Text>
<Text style={{ color: tokens.mutedForeground, fontSize: 18 }}>
{advancedOpen ? "⌃" : "⌄"}
</Text>
</Pressable>
{advancedOpen ? (
<View>
<Text
style={{
color: tokens.mutedForeground,
marginTop: 8,
marginBottom: 8,
fontSize: 14,
}}
>
{t("Model")}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Model")}
onPress={openModelPicker}
style={{
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.muted,
borderRadius: 11,
paddingVertical: 12,
paddingHorizontal: 16,
}}
>
<Text style={{ color: tokens.foreground }}>{selectedModelLabel}</Text>
</Pressable>
{thinkingOptions.length ? (
<>
<Text
style={{
color: tokens.mutedForeground,
marginTop: 16,
marginBottom: 8,
fontSize: 14,
}}
>
{t("Thinking")}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Thinking")}
onPress={openThinkingPicker}
style={{
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.muted,
borderRadius: 11,
paddingVertical: 12,
paddingHorizontal: 16,
}}
>
<Text style={{ color: tokens.foreground }}>{selectedThinkingLabel}</Text>
</Pressable>
</>
) : null}
{modelMetaError ? (
<Text style={{ color: tokens.mutedForeground, marginTop: 12, fontSize: 13 }}>
{modelMetaError}
</Text>
) : null}
</View>
) : null}
{error ? <Text style={{ color: tokens.destructive, marginTop: 16 }}>{error}</Text> : null}
<Pressable
onPress={() => void save()}
disabled={!name.trim() || pending || !bot}
style={{
marginTop: 24,
backgroundColor: tokens.primary,
borderRadius: 11,
padding: 16,
alignItems: "center",
opacity: !name.trim() || pending || !bot ? 0.4 : 1,
}}
>
<Text style={{ color: tokens.primaryForeground, fontSize: 16 }}>
{pending ? t("Saving…") : t("Save")}
</Text>
</Pressable>
</ScrollView>
</>
);
}
function modelOptionKey(provider: string, modelId: string) {
return `${provider}::${modelId}`;
}
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: MobileModel[],
provider: string | null | undefined,
modelId: string,
) {
if (!provider) return undefined;
return catalog.find((entry) => entry.provider === provider && entry.id === modelId)?.label;
}
function thinkingLevelLabel(level: ThinkingLevel, t: (message: string) => string) {
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)}`;
}