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.
510 lines
17 KiB
TypeScript
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)}`;
|
|
}
|