BangSo/apps/mobile/app/bot-settings.tsx

356 lines
12 KiB
TypeScript

import type { AvatarShape, ComputerStatus } from "@rakazo/contracts";
import {
AVATAR_SHAPES,
BOT_COLORS,
BOT_DESCRIPTION_MAX_LENGTH,
BOT_NAME_MAX_LENGTH,
BOT_TITLE_MAX_LENGTH,
type ComputerMode,
normalizeCreateBotProfile,
} from "@rakazo/contracts";
import { botAvatarImageSrc, nextGeneratedAvatarFace } from "@rakazo/core";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { BotAvatar } from "../components/bot-avatar";
import { ComputerMaintenanceActions } from "../components/computer-maintenance-actions";
import { ComputerModePicker } from "../components/computer-mode-picker";
import { type MobileBot, rpc } from "../lib/api";
import { pickFromLibrary } from "../lib/pick-attachments";
type BotSettingsRecord = MobileBot & {
description?: string;
};
const AVATAR_SHAPE_LABELS: Record<AvatarShape, string> = {
circle: "Circle",
oval: "Oval",
"rounded-square": "Rounded square",
pill: "Pill",
triangle: "Triangle",
hexagon: "Hexagon",
cloud: "Cloud",
teardrop: "Teardrop",
};
export default function BotSettingsScreen() {
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 [computerMode, setComputerMode] = useState<ComputerMode>("team");
const [computer, setComputer] = useState<ComputerStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
if (!botId) return;
void Promise.all([
rpc<BotSettingsRecord>("bots/get", { botId }),
rpc<ComputerStatus>("computer/status", { botId }).catch(() => null),
])
.then(([next, status]) => {
setBot(next);
setName(next.name);
setTitle(next.title);
setDescription(next.description ?? "");
setComputerMode(next.computerMode);
setComputer(status);
})
.catch((err) => setError(err instanceof Error ? err.message : "Could not load bot"));
}, [botId]);
async function applyAvatar(patch: {
color?: string;
avatarShape?: AvatarShape | null;
avatarImageArtifactId?: string | null;
}) {
if (!botId || !bot || pending) return;
const previous = bot;
setBot({
...bot,
...(patch.color !== undefined ? { color: patch.color } : {}),
...(patch.avatarShape !== undefined ? { avatarShape: patch.avatarShape } : {}),
...(patch.avatarImageArtifactId !== undefined
? { hasAvatarImage: patch.avatarImageArtifactId !== null }
: {}),
});
setPending(true);
setError(null);
try {
const next = await rpc<BotSettingsRecord>("bots/update", { botId, ...patch });
setBot(next);
} catch (err) {
setBot(previous);
setError(err instanceof Error ? err.message : "Could not update avatar");
} finally {
setPending(false);
}
}
async function uploadAvatar() {
if (!botId || pending) return;
const picked = await pickFromLibrary(0);
const file = picked.attachments[0];
if (!file) return;
setPending(true);
setError(null);
const previous = bot;
try {
const artifact = await rpc<{ id: string }>("artifacts/create", {
botId,
name: file.name,
mimeType: file.mimeType,
contentBase64: file.contentBase64,
});
if (bot) setBot({ ...bot, hasAvatarImage: true });
const next = await rpc<BotSettingsRecord>("bots/update", {
botId,
avatarImageArtifactId: artifact.id,
});
setBot(next);
} catch (err) {
if (previous) setBot(previous);
setError(err instanceof Error ? err.message : "Could not update avatar");
} finally {
setPending(false);
}
}
async function save() {
if (!botId || !bot || pending) return;
setPending(true);
setError(null);
try {
const profile = normalizeCreateBotProfile({ name, title, description });
const input: {
botId: string;
name?: string;
title?: string;
description?: string;
instructions?: string;
} = { 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 (computerMode !== bot.computerMode) {
await rpc("bots/setComputer", { botId, mode: computerMode });
}
// 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 : "Could not save bot");
} finally {
setPending(false);
}
}
return (
<>
<Stack.Screen options={{ title: "Chat settings" }} />
<ScrollView
style={{ flex: 1, backgroundColor: "#050506" }}
contentContainerStyle={{ padding: 24 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{bot ? (
<View style={{ alignItems: "center", marginBottom: 24 }}>
<BotAvatar
color={bot.color}
identity={bot.id}
size={64}
status={bot.status}
shape={bot.avatarShape}
imageSrc={botAvatarImageSrc(bot)}
/>
<View
style={{
marginTop: 16,
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: 8,
maxWidth: 280,
}}
>
{AVATAR_SHAPES.map((shape) => {
const selected = (bot.avatarShape ?? "circle") === shape && !bot.hasAvatarImage;
return (
<Pressable
key={shape}
accessibilityLabel={shape}
accessibilityState={{ selected }}
disabled={pending}
onPress={() =>
void applyAvatar({ avatarShape: shape, avatarImageArtifactId: null })
}
style={{
width: 52,
height: 68,
borderRadius: 12,
borderWidth: 1,
borderColor: selected ? "#5A5A62" : "#26262A",
backgroundColor: selected ? "#1A1A1D" : "transparent",
alignItems: "center",
justifyContent: "center",
}}
>
<BotAvatar
color={bot.color}
identity={bot.id}
size={28}
shape={shape}
variant="organic"
/>
<Text
numberOfLines={1}
style={{
marginTop: 3,
color: selected ? "#ECECEE" : "#85858A",
fontSize: 9,
}}
>
{AVATAR_SHAPE_LABELS[shape]}
</Text>
</Pressable>
);
})}
</View>
<View style={{ marginTop: 12, flexDirection: "row", gap: 10 }}>
{BOT_COLORS.map((swatch) => {
const selected = bot.color.toLowerCase() === swatch.toLowerCase();
return (
<Pressable
key={swatch}
accessibilityLabel={swatch}
accessibilityState={{ selected }}
disabled={pending}
onPress={() => void applyAvatar({ color: swatch })}
style={{
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: swatch,
borderWidth: selected ? 2 : 0,
borderColor: "#ECECEE",
}}
/>
);
})}
</View>
<View style={{ marginTop: 16, flexDirection: "row", gap: 16 }}>
<Pressable
disabled={pending}
onPress={() => {
const generated = nextGeneratedAvatarFace({
shape: bot.avatarShape,
color: bot.color,
});
void applyAvatar({
avatarShape: generated.shape,
color: generated.color,
avatarImageArtifactId: null,
});
}}
>
<Text style={{ color: "#C9C9CE", fontSize: 14 }}>Generate</Text>
</Pressable>
<Pressable disabled={pending} onPress={() => void uploadAvatar()}>
<Text style={{ color: "#C9C9CE", fontSize: 14 }}>Upload</Text>
</Pressable>
<Pressable
disabled={pending || (!bot.avatarShape && !bot.hasAvatarImage)}
onPress={() => void applyAvatar({ avatarShape: null, avatarImageArtifactId: null })}
>
<Text style={{ color: "#C9C9CE", fontSize: 14 }}>Reset</Text>
</Pressable>
</View>
</View>
) : null}
<Text style={{ color: "#85858A", fontSize: 14 }}>Name</Text>
<TextInput
value={name}
maxLength={BOT_NAME_MAX_LENGTH}
onChangeText={setName}
placeholder="Name this bot"
placeholderTextColor="#6C6C70"
style={{
marginTop: 8,
backgroundColor: "#1A1A1D",
borderRadius: 11,
padding: 16,
color: "#ECECEE",
}}
/>
<Text style={{ color: "#85858A", marginTop: 16, fontSize: 14 }}>Title</Text>
<TextInput
value={title}
maxLength={BOT_TITLE_MAX_LENGTH}
onChangeText={setTitle}
placeholder="Describe what this bot does"
placeholderTextColor="#6C6C70"
style={{
marginTop: 8,
backgroundColor: "#1A1A1D",
borderRadius: 11,
padding: 16,
color: "#ECECEE",
}}
/>
<Text style={{ color: "#85858A", marginTop: 16, fontSize: 14 }}>Description</Text>
<TextInput
value={description}
maxLength={BOT_DESCRIPTION_MAX_LENGTH}
onChangeText={setDescription}
placeholder="What this bot is for"
placeholderTextColor="#6C6C70"
multiline
style={{
marginTop: 8,
backgroundColor: "#1A1A1D",
borderRadius: 11,
padding: 16,
color: "#ECECEE",
minHeight: 120,
textAlignVertical: "top",
}}
/>
<ComputerModePicker value={computerMode} onChange={setComputerMode} />
<ComputerMaintenanceActions
botId={botId}
computer={computer}
onChanged={async () => {
const status = await rpc<ComputerStatus>("computer/status", { botId });
setComputer(status);
}}
/>
{error ? <Text style={{ color: "#E65707", marginTop: 16 }}>{error}</Text> : null}
<Pressable
onPress={() => void save()}
disabled={!name.trim() || pending || !bot}
style={{
marginTop: 24,
backgroundColor: "#F1F1EF",
borderRadius: 11,
padding: 16,
alignItems: "center",
opacity: !name.trim() || pending || !bot ? 0.4 : 1,
}}
>
<Text style={{ color: "#17171A", fontSize: 16 }}>{pending ? "Saving…" : "Save"}</Text>
</Pressable>
</ScrollView>
</>
);
}