Let a bot take any colour
A bot's identity was one of seven hues, and the hue picked both its colour and its stone shape. Now its colour is free and its stone is its own: - `bots.color` holds any `#rrggbb`, `bots.stone` the flat-cut shape it's drawn on. `bots.hue` stays as the palette column they replaced, read only as a fallback; `src/scripts/move-bot-colour-to-owners.ts` backfills an install from it (safe to rerun). - `src/utils/bot-color.ts` is the only place a colour is validated or resolved (`resolveBotColor`/`resolveBotStone` carry the old hue forward, `botColorNeedsPaperFace` flips the eyes to paper on a very dark body), and `src/utils/bot-stone-props.ts` gives every row its props, so a row saved before this still renders as it did. - `BotStone` takes the colour as the body's SVG `fill`, the one documented exception to the token rule: an arbitrary hex has no token to point at. `UnreadDot` follows it. - The picker keeps the palette tiles (still shape and colour together) and adds a native swatch and a hex field, so a custom colour keeps its stone. Same place as before: the header avatar's modal and Settings > Bots, for crew managers. The coordinator stays hue-less. - A recolour reaches other open tabs through `pushBotUpdated`.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
|
||||
import { logError } from "@/src/functions/backend/logger";
|
||||
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
|
||||
@@ -61,6 +63,13 @@ async function push({
|
||||
workspace_ids,
|
||||
}: Params) {
|
||||
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
|
||||
/** Its colour and stone, so a recolour reaches every open tab. */
|
||||
const identity = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT color, stone FROM bots WHERE id = ? LIMIT 1`,
|
||||
values: [bot_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
for (const viewer_id of audience) {
|
||||
if (users && !users.has(String(viewer_id))) continue;
|
||||
@@ -72,6 +81,8 @@ async function push({
|
||||
data: {
|
||||
id: Number(bot_id),
|
||||
...(await grabBotFace({ bot_id, user_id: viewer_id })),
|
||||
color: identity?.color || null,
|
||||
stone: identity?.stone || null,
|
||||
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
|
||||
...(workspace_ids !== undefined ? { workspace_ids } : {}),
|
||||
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
|
||||
|
||||
@@ -29,7 +29,7 @@ type Props = ComponentProps<typeof BotStone> & {
|
||||
/**
|
||||
* A bot's avatar: its custom picture when it has one (a rounded square,
|
||||
* never a circle, so it isn't mistaken for a person), else its stone. Its
|
||||
* hue still marks it everywhere else, e.g. the unread dot.
|
||||
* colour still marks it everywhere else, e.g. the unread dot.
|
||||
*/
|
||||
export default function BotAvatar({ image_media_id, src: picked, ...stone }: Props) {
|
||||
const saved = profileImageUrls({ media_id: image_media_id });
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotHueFillClasses } from "@/src/dict/bot-hue-classes-dict";
|
||||
import type { BotFace, BotStoneShape } from "@/src/dict/bot-dict";
|
||||
import {
|
||||
CoordinatorBodyPath,
|
||||
StoneBodyPaths,
|
||||
StoneViewBox,
|
||||
} from "@/src/data/stone-geometry";
|
||||
import { botColorNeedsPaperFace, resolveBotStone } from "@/src/utils/bot-color";
|
||||
import BotStoneFace from "./bot-stone-face";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The bot's identity hue. Omit (or `null`) for the coordinator, which
|
||||
* renders hue-less: ink body, paper eyes, inverted in dark mode.
|
||||
* The bot's identity colour (`#rrggbb`). Omit (or `null`) for the
|
||||
* coordinator, which renders hue-less: ink body, paper eyes, inverted in
|
||||
* dark mode.
|
||||
*
|
||||
* A bot's colour is user data, so it reaches the SVG as a `fill`
|
||||
* attribute rather than a theme token or a Tailwind class: an arbitrary
|
||||
* hex has no token to point at. This is the one place the palette rule
|
||||
* bends; everything else in the app still uses `--color-*`.
|
||||
*/
|
||||
hue?: BotHue | null;
|
||||
color?: string | null;
|
||||
/**
|
||||
* Which flat-cut stone to draw. Defaults to the shape this bot used to
|
||||
* be tied to, else the first one.
|
||||
*/
|
||||
stone?: BotStoneShape | null;
|
||||
/**
|
||||
* Historical palette hue, only to pick the shape and colour of a bot
|
||||
* saved before they were stored apart.
|
||||
*/
|
||||
hue?: string | null;
|
||||
face?: BotFace;
|
||||
size?: number;
|
||||
/**
|
||||
@@ -30,17 +46,25 @@ type Props = {
|
||||
* eyes. Never put it in a circle, ring or badge, or add an outline or shadow.
|
||||
*/
|
||||
export default function BotStone({
|
||||
color,
|
||||
stone,
|
||||
hue,
|
||||
face = "idle",
|
||||
size = 32,
|
||||
label,
|
||||
className,
|
||||
}: Props) {
|
||||
const is_coordinator = !hue;
|
||||
const body_path = hue ? StoneBodyPaths[hue] : CoordinatorBodyPath;
|
||||
const body_class = hue ? BotHueFillClasses[hue] : "fill-ink";
|
||||
const eye_fill = is_coordinator ? "fill-paper" : "fill-dark";
|
||||
const eye_stroke = is_coordinator ? "stroke-paper" : "stroke-dark";
|
||||
const is_coordinator = !color;
|
||||
const body_path = is_coordinator
|
||||
? CoordinatorBodyPath
|
||||
: StoneBodyPaths[resolveBotStone({ stone, hue })];
|
||||
/**
|
||||
* A very dark body keeps its eyes readable in paper; the coordinator is
|
||||
* always paper, since its body is ink.
|
||||
*/
|
||||
const paper_face = is_coordinator || botColorNeedsPaperFace({ color: color! });
|
||||
const eye_fill = paper_face ? "fill-paper" : "fill-dark";
|
||||
const eye_stroke = paper_face ? "stroke-paper" : "stroke-dark";
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -52,7 +76,11 @@ export default function BotStone({
|
||||
aria-hidden={label ? undefined : true}
|
||||
className={twMerge("shrink-0", className)}
|
||||
>
|
||||
<path d={body_path} className={body_class} />
|
||||
<path
|
||||
d={body_path}
|
||||
fill={is_coordinator ? undefined : color!}
|
||||
className={is_coordinator ? "fill-ink" : undefined}
|
||||
/>
|
||||
<BotStoneFace
|
||||
face={face}
|
||||
fill_class={eye_fill}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { BotHues } from "@/src/dict/bot-dict";
|
||||
import BotStone from "./bot-stone";
|
||||
|
||||
type Props = {
|
||||
@@ -13,7 +14,8 @@ type Props = {
|
||||
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
|
||||
return (
|
||||
<BotStone
|
||||
hue="ember"
|
||||
color={BotHues[0].color}
|
||||
stone={BotHues[0].value}
|
||||
size={icon_size}
|
||||
label="Logo Icon"
|
||||
className={img_props?.className}
|
||||
|
||||
@@ -6,7 +6,8 @@ import Button from "@moduletrace/twui/layout/Button";
|
||||
import type { TaskWithNames } from "@/src/types";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import { TaskStatuses } from "@/src/dict/task-dict";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
@@ -40,7 +41,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
|
||||
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<BotAvatar
|
||||
image_media_id={task.bot_image_media_id}
|
||||
hue={(task.bot_kind == "coordinator" ? null : task.bot_hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: task.bot_color, stone: task.bot_stone, hue: task.bot_hue })}
|
||||
face={(task.bot_face || "idle") as BotFace}
|
||||
size={28}
|
||||
/>
|
||||
|
||||
+52
-2
@@ -1,40 +1,90 @@
|
||||
/**
|
||||
* Bot identity hues. A bot keeps its hue for life; a hue never means state.
|
||||
* The coordinator has no hue.
|
||||
* The palette a bot starts from: a suggested colour and the stone shape that
|
||||
* goes with it. A bot's colour is free (any `#rrggbb`), so these are only
|
||||
* the tiles the picker offers and the colours a new bot is given. Shape is
|
||||
* stored separately (`bots.stone`), so a custom colour keeps its stone.
|
||||
*/
|
||||
export const BotHues = [
|
||||
{
|
||||
title: "Ember",
|
||||
value: "ember",
|
||||
color: "#e4572e",
|
||||
},
|
||||
{
|
||||
title: "Amber",
|
||||
value: "amber",
|
||||
color: "#e9a23b",
|
||||
},
|
||||
{
|
||||
title: "Moss",
|
||||
value: "moss",
|
||||
color: "#7fae3c",
|
||||
},
|
||||
{
|
||||
title: "Teal",
|
||||
value: "teal",
|
||||
color: "#16a394",
|
||||
},
|
||||
{
|
||||
title: "Cobalt",
|
||||
value: "cobalt",
|
||||
color: "#3b7dd8",
|
||||
},
|
||||
{
|
||||
title: "Violet",
|
||||
value: "violet",
|
||||
color: "#7c5cd6",
|
||||
},
|
||||
{
|
||||
title: "Rose",
|
||||
value: "rose",
|
||||
color: "#d6538c",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Historical palette name. Kept for `bots.hue`, which is only read as a
|
||||
* fallback for a bot saved before `color` and `stone` existed.
|
||||
*/
|
||||
export type BotHue = (typeof BotHues)[number]["value"];
|
||||
|
||||
/**
|
||||
* The stone shapes. One flat cut each, from the handoff icon set. Shape is
|
||||
* the bot's own, independent of its colour.
|
||||
*/
|
||||
export const BotStoneShapes = [
|
||||
{
|
||||
title: "Ember stone",
|
||||
value: "ember",
|
||||
},
|
||||
{
|
||||
title: "Amber stone",
|
||||
value: "amber",
|
||||
},
|
||||
{
|
||||
title: "Moss stone",
|
||||
value: "moss",
|
||||
},
|
||||
{
|
||||
title: "Teal stone",
|
||||
value: "teal",
|
||||
},
|
||||
{
|
||||
title: "Cobalt stone",
|
||||
value: "cobalt",
|
||||
},
|
||||
{
|
||||
title: "Violet stone",
|
||||
value: "violet",
|
||||
},
|
||||
{
|
||||
title: "Rose stone",
|
||||
value: "rose",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type BotStoneShape = (typeof BotStoneShapes)[number]["value"];
|
||||
|
||||
export const BotKinds = [
|
||||
{
|
||||
title: "Worker",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { BotHue } from "./bot-dict";
|
||||
|
||||
/**
|
||||
* Static Tailwind class names per hue, so the classes survive the build.
|
||||
*/
|
||||
export const BotHueFillClasses: Record<BotHue, string> = {
|
||||
ember: "fill-hue-ember",
|
||||
amber: "fill-hue-amber",
|
||||
moss: "fill-hue-moss",
|
||||
teal: "fill-hue-teal",
|
||||
cobalt: "fill-hue-cobalt",
|
||||
violet: "fill-hue-violet",
|
||||
rose: "fill-hue-rose",
|
||||
};
|
||||
|
||||
export const BotHueBgClasses: Record<BotHue, string> = {
|
||||
ember: "bg-hue-ember",
|
||||
amber: "bg-hue-amber",
|
||||
moss: "bg-hue-moss",
|
||||
teal: "bg-hue-teal",
|
||||
cobalt: "bg-hue-cobalt",
|
||||
violet: "bg-hue-violet",
|
||||
rose: "bg-hue-rose",
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/
|
||||
export default async function grabCrewOptions() {
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT id, name, role, hue, image_media_id FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`,
|
||||
sql: `SELECT id, name, role, color, stone, hue, image_media_id FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`,
|
||||
}),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
sql: `SELECT id, name, description, image_media_id FROM workspaces WHERE archived = 0 ORDER BY name COLLATE NOCASE ASC, id ASC`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotStoneShape } from "@/src/dict/bot-dict";
|
||||
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||
@@ -12,7 +12,14 @@ type Params = {
|
||||
user_id: number | string;
|
||||
name: string;
|
||||
role?: string;
|
||||
hue: BotHue;
|
||||
/**
|
||||
* Its identity colour, `#rrggbb`.
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* Which flat-cut stone it's drawn on.
|
||||
*/
|
||||
stone: BotStoneShape;
|
||||
/**
|
||||
* Its core instructions (markdown).
|
||||
*/
|
||||
@@ -26,7 +33,7 @@ type Params = {
|
||||
* computer of its own until it's given one; in a workspace it uses the
|
||||
* workspace's.
|
||||
*/
|
||||
export default async function createBot({ user_id, name, role, hue, system_prompt }: Params) {
|
||||
export default async function createBot({ user_id, name, role, color, stone, system_prompt }: Params) {
|
||||
await assertCrewManager({ user_id });
|
||||
|
||||
assertPromptLength({ label: "Instructions", text: system_prompt });
|
||||
@@ -41,7 +48,8 @@ export default async function createBot({ user_id, name, role, hue, system_promp
|
||||
user_id: Number(user_id),
|
||||
name,
|
||||
kind: "worker",
|
||||
hue,
|
||||
color,
|
||||
stone,
|
||||
role: role || undefined,
|
||||
system_prompt,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotStoneShape } from "@/src/dict/bot-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||
import grabUserBot from "./grab-user-bot";
|
||||
@@ -10,16 +10,31 @@ type Params = {
|
||||
bot_id: number | string;
|
||||
name: string;
|
||||
role?: string;
|
||||
hue: BotHue;
|
||||
/**
|
||||
* Its identity colour, `#rrggbb`.
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* Which flat-cut stone it's drawn on.
|
||||
*/
|
||||
stone: BotStoneShape;
|
||||
system_prompt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves one of the user's bots: name, job, hue and core instructions. The
|
||||
* coordinator keeps its name and stays hue-less, so only its instructions
|
||||
* change. Returns the saved bot.
|
||||
* Saves one of the user's bots: name, job, colour, stone and core
|
||||
* instructions. The coordinator keeps its name and stays hue-less, so only
|
||||
* its instructions change. Returns the saved bot.
|
||||
*/
|
||||
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
|
||||
export default async function updateBot({
|
||||
user_id,
|
||||
bot_id,
|
||||
name,
|
||||
role,
|
||||
color,
|
||||
stone,
|
||||
system_prompt,
|
||||
}: Params) {
|
||||
await assertCrewManager({ user_id });
|
||||
|
||||
assertPromptLength({ label: "Instructions", text: system_prompt });
|
||||
@@ -37,8 +52,8 @@ export default async function updateBot({ user_id, bot_id, name, role, hue, syst
|
||||
});
|
||||
} else {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots SET name = ?, role = ?, hue = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [name, role || null, hue, system_prompt, Date.now(), bot.id],
|
||||
sql: `UPDATE bots SET name = ?, role = ?, color = ?, stone = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [name, role || null, color, stone, system_prompt, Date.now(), bot.id],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Par
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql<CronJobWithNames>({
|
||||
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, ${face.sql},
|
||||
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone, ${face.sql},
|
||||
b.image_media_id AS bot_image_media_id, w.name AS workspace_name
|
||||
FROM cron_jobs c
|
||||
JOIN bots b ON b.id = c.bot_id
|
||||
|
||||
@@ -25,7 +25,7 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
|
||||
const face = botFaceSql({ user_id, alias: "b", prefix: "bot_" });
|
||||
|
||||
const row_res = await BunSQLite.sql<TaskDetail>({
|
||||
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
|
||||
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
|
||||
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
|
||||
FROM tasks t
|
||||
LEFT JOIN bots b ON b.id = t.bot_id
|
||||
|
||||
@@ -83,7 +83,7 @@ export default async function grabTasks({
|
||||
const face = botFaceSql({ user_id: user_id || 0, alias: "b", prefix: "bot_" });
|
||||
|
||||
const res = await BunSQLite.sql<TaskWithNames>({
|
||||
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
|
||||
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
|
||||
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
|
||||
(SELECT n.text FROM task_notes n WHERE n.task_id = t.id ORDER BY n.id DESC LIMIT 1) AS latest_note,
|
||||
(SELECT COUNT(*) FROM task_media tm WHERE tm.task_id = t.id) AS media_count
|
||||
|
||||
@@ -24,6 +24,8 @@ export default async function grabWorkspaceActivity({
|
||||
b.name AS bot_name,
|
||||
b.image_media_id AS bot_image_media_id,
|
||||
b.hue AS bot_hue,
|
||||
b.color AS bot_color,
|
||||
b.stone AS bot_stone,
|
||||
${face.sql},
|
||||
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
|
||||
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AppData } from "@/src/data/app-data";
|
||||
import pushBotCreated from "@/src/bots-queue/push-bot-created";
|
||||
import createBot from "@/src/functions/backend/db/bots/create-bot";
|
||||
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
||||
import pickFreeHue from "@/src/utils/pick-free-hue";
|
||||
import pickFreeColor from "@/src/utils/pick-free-color";
|
||||
import botRef from "@/src/utils/bot-ref";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
@@ -76,19 +76,24 @@ export default function createBotTool({
|
||||
);
|
||||
}
|
||||
|
||||
const hues =
|
||||
const colors =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT hue FROM bots`,
|
||||
sql: `SELECT color FROM bots`,
|
||||
values: [],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
const { color, stone } = pickFreeColor({
|
||||
taken: colors.map((bot) => bot.color),
|
||||
});
|
||||
|
||||
const bot_id = await createBot({
|
||||
user_id,
|
||||
name: args.name,
|
||||
role: args.role,
|
||||
hue: pickFreeHue({ taken: hues.map((bot) => bot.hue) }),
|
||||
color,
|
||||
stone,
|
||||
system_prompt: args.instructions,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||
import updateBot from "@/src/functions/backend/db/bots/update-bot";
|
||||
import { paletteColor, resolveBotStone } from "@/src/utils/bot-color";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
@@ -24,7 +24,8 @@ type Args = {
|
||||
/**
|
||||
* Lets the coordinator change an existing crew member: its name, its
|
||||
* one-line job, and its core instructions (the start of its prompt in its
|
||||
* own chat and in every workspace). Its hue and picture stay as they are.
|
||||
* own chat and in every workspace). Its colour, stone and picture stay as
|
||||
* they are.
|
||||
* Workspace-only instructions have their own tool
|
||||
* (`set_bot_workspace_instructions`).
|
||||
*/
|
||||
@@ -71,7 +72,13 @@ export default function updateBotTool({ user_id, workers }: Params): HarnessTool
|
||||
bot_id: worker.id,
|
||||
name: args.name ?? worker.name ?? "Bot",
|
||||
role: args.role ?? worker.role ?? "",
|
||||
hue: (worker.hue || "ember") as BotHue,
|
||||
/**
|
||||
* Never changed here: the user's pick is theirs. A bot saved
|
||||
* before colours were free keeps the colour it showed.
|
||||
*/
|
||||
color:
|
||||
worker.color || paletteColor({ hue: worker.hue }) || "#e4572e",
|
||||
stone: resolveBotStone({ stone: worker.stone, hue: worker.hue }),
|
||||
system_prompt: args.instructions ?? worker.system_prompt ?? "",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES, BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import type {
|
||||
BotCreatedData,
|
||||
BotRemovedData,
|
||||
@@ -78,7 +78,19 @@ export default function useLiveBots({ bots, open_bot_id, workspace_id = null }:
|
||||
|
||||
return prev.map((bot) =>
|
||||
bot.id == data.id
|
||||
? { ...bot, face: data.face, status_line: data.status_line }
|
||||
? {
|
||||
...bot,
|
||||
face: data.face,
|
||||
status_line: data.status_line,
|
||||
...(data.color !== undefined
|
||||
? { color: data.color || undefined }
|
||||
: {}),
|
||||
...(data.stone !== undefined
|
||||
? {
|
||||
stone: (data.stone || "") as BUN_SQLITE_COBBLE_BOTS["stone"],
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: bot,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -2,8 +2,9 @@ import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import UnreadDot from "./unread-dot";
|
||||
|
||||
@@ -16,7 +17,7 @@ type Props = {
|
||||
* One bot in the all-bots modal: stone, name, job, last activity.
|
||||
*/
|
||||
export default function AllBotsRow({ bot, href }: Props) {
|
||||
const hue = (bot.hue || null) as BotHue | null;
|
||||
const stone_props = botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue });
|
||||
const at = bot.last_message_at || bot.last_activity_at;
|
||||
|
||||
return (
|
||||
@@ -25,7 +26,7 @@ export default function AllBotsRow({ bot, href }: Props) {
|
||||
title={bot.name}
|
||||
className="flex h-14 items-center gap-3 rounded-default px-3 border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
|
||||
<BotAvatar image_media_id={bot.image_media_id} {...stone_props} face={(bot.face || "idle") as BotFace} />
|
||||
<Stack className="grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{bot.name}</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
@@ -37,7 +38,7 @@ export default function AllBotsRow({ bot, href }: Props) {
|
||||
{relativeTime({ at: Number(at) })}
|
||||
</Span>
|
||||
) : null}
|
||||
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
|
||||
{bot.unread && stone_props.color ? <UnreadDot color={stone_props.color} /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-3
@@ -15,7 +15,7 @@ type Props = (typeof BotHues)[number] & {
|
||||
* One colour tile: a stone in that hue over its name. Selected is a strong
|
||||
* border, so it never depends on the colour alone.
|
||||
*/
|
||||
export default function BotHueOption({ title, value, selected, disabled, onSelect }: Props) {
|
||||
export default function BotColourOption({ title, value, color, selected, disabled, onSelect }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
@@ -35,8 +35,18 @@ export default function BotHueOption({ title, value, selected, disabled, onSelec
|
||||
)}
|
||||
>
|
||||
<Stack className="w-full min-w-0 gap-1! items-center">
|
||||
<BotStone hue={value} face={selected ? "done" : "idle"} size={28} />
|
||||
<Span className={twMerge("text-xs leading-4 text-ink-secondary truncate", selected && "font-bold text-ink")}>
|
||||
<BotStone
|
||||
color={color}
|
||||
stone={value}
|
||||
face={selected ? "done" : "idle"}
|
||||
size={28}
|
||||
/>
|
||||
<Span
|
||||
className={twMerge(
|
||||
"text-xs leading-4 text-ink-secondary truncate",
|
||||
selected && "font-bold text-ink",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Span>
|
||||
</Stack>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { BotHues, type BotStoneShape } from "@/src/dict/bot-dict";
|
||||
import { normalizeBotColor, paletteColorTitle } from "@/src/utils/bot-color";
|
||||
import BotColourOption from "./bot-colour-option";
|
||||
import BotColourSwatch from "./bot-colour-swatch";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The bot's colour as `#rrggbb`.
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* Which stone shape it's drawn on. Picking a palette tile sets both, so a
|
||||
* palette bot looks exactly as it always did.
|
||||
*/
|
||||
stone: BotStoneShape;
|
||||
disabled?: boolean;
|
||||
onChange: (picked: { color: string; stone: BotStoneShape }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks a bot's colour: the palette tiles (each on its own stone), or any
|
||||
* colour at all from the swatch or the hex field. A custom colour keeps the
|
||||
* stone the bot already has, so its shape stays its own.
|
||||
*/
|
||||
export default function BotColourPicker({ color, stone, disabled, onChange }: Props) {
|
||||
const [hex, setHex] = useState(color);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const picked = paletteValueFor({ color });
|
||||
const palette_title = paletteColorTitle({ color });
|
||||
|
||||
useEffect(() => setHex(color), [color]);
|
||||
|
||||
const commitHex = (value: string) => {
|
||||
setHex(value);
|
||||
|
||||
const normalized = normalizeBotColor({ color: value });
|
||||
|
||||
setInvalid(Boolean(value.trim()) && !normalized);
|
||||
|
||||
if (normalized) onChange({ color: normalized, stone });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-2!">
|
||||
<Span className="text-xs text-ink-secondary">Colour</Span>
|
||||
<Row
|
||||
role="radiogroup"
|
||||
aria-label="Colour"
|
||||
className="w-full grid grid-cols-4 sm:grid-cols-7 gap-2!"
|
||||
>
|
||||
{BotHues.map((option) => (
|
||||
<BotColourOption
|
||||
key={option.value}
|
||||
{...option}
|
||||
selected={option.value == picked}
|
||||
disabled={disabled}
|
||||
onSelect={() =>
|
||||
onChange({ color: option.color, stone: option.value })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
<Row className="w-full flex-nowrap items-end gap-2!">
|
||||
<Stack className="shrink-0 gap-1!">
|
||||
<Span className="text-xs text-ink-secondary">Any colour</Span>
|
||||
<BotColourSwatch
|
||||
color={color}
|
||||
disabled={disabled}
|
||||
title="Pick a colour"
|
||||
onChange={(next) => onChange({ color: next, stone })}
|
||||
/>
|
||||
</Stack>
|
||||
<Input
|
||||
type="text"
|
||||
id="bot-form-color-hex"
|
||||
title="Hex value"
|
||||
label="Hex value"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
placeholder="#7c5cd6"
|
||||
value={hex}
|
||||
disabled={disabled}
|
||||
className="grow"
|
||||
variant={invalid ? "error" : "normal"}
|
||||
info={invalid ? "Use a colour like #7c5cd6" : undefined}
|
||||
onChange={(e) => commitHex(e.target.value)}
|
||||
onBlur={() => !invalid && setHex(color)}
|
||||
/>
|
||||
</Row>
|
||||
<Span className="text-xs text-ink-secondary">
|
||||
{palette_title
|
||||
? "A palette colour. Pick any colour, or type a hex, to make it its own."
|
||||
: "Its own colour. Its stone keeps its shape."}
|
||||
</Span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette entry a colour is, by comparing hexes.
|
||||
*/
|
||||
function paletteValueFor({ color }: { color: string }): string | null {
|
||||
const normalized = normalizeBotColor({ color });
|
||||
|
||||
if (!normalized) return null;
|
||||
|
||||
return BotHues.find((entry) => entry.color == normalized)?.value || null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The colour as `#rrggbb`; `input[type=color]` only takes that form.
|
||||
*/
|
||||
color: string;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
onChange: (color: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The native colour picker, dressed as a swatch. twui has no colour input,
|
||||
* and its `Input` wraps every type in a padded field with a clear button,
|
||||
* which a swatch doesn't want, so this is the field itself. The value goes
|
||||
* straight into the same `onChange` the hex field and the tiles use.
|
||||
*/
|
||||
export default function BotColourSwatch({ color, disabled, title, onChange }: Props) {
|
||||
return (
|
||||
<input
|
||||
type="color"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
value={color}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={twMerge(
|
||||
"size-9 shrink-0 cursor-pointer appearance-none rounded-default",
|
||||
"border border-solid border-hairline bg-surface p-0.5",
|
||||
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotStoneShape } from "@/src/dict/bot-dict";
|
||||
import { paletteColorTitle } from "@/src/utils/bot-color";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
@@ -11,7 +12,8 @@ type Props = {
|
||||
/**
|
||||
* Null for the coordinator.
|
||||
*/
|
||||
hue: BotHue | null;
|
||||
color: string | null;
|
||||
stone: BotStoneShape;
|
||||
image_media_id?: number | string | null;
|
||||
picked_src?: string;
|
||||
};
|
||||
@@ -21,23 +23,50 @@ type Props = {
|
||||
* or stone, name and job, and its colour (a small stone beside the name
|
||||
* when a picture hides the big one).
|
||||
*/
|
||||
export default function BotFormPreview({ name, role, hue, image_media_id, picked_src }: Props) {
|
||||
export default function BotFormPreview({
|
||||
name,
|
||||
role,
|
||||
color,
|
||||
stone,
|
||||
image_media_id,
|
||||
picked_src,
|
||||
}: Props) {
|
||||
const has_picture = Boolean(picked_src || image_media_id);
|
||||
const hue_title = BotHues.find((h) => h.value == hue)?.title;
|
||||
/** A palette colour by name, else a colour the user made up. */
|
||||
const color_title = color
|
||||
? paletteColorTitle({ color }) || "Its own colour"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Row className="min-w-0 grow gap-3! flex-nowrap items-center">
|
||||
<BotAvatar hue={hue} size={56} image_media_id={image_media_id} src={picked_src} />
|
||||
<BotAvatar
|
||||
color={color}
|
||||
stone={stone}
|
||||
size={56}
|
||||
image_media_id={image_media_id}
|
||||
src={picked_src}
|
||||
/>
|
||||
<Stack className="min-w-0 gap-0!">
|
||||
<Row className="min-w-0 w-full gap-1.5! flex-nowrap items-center">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate">{name.trim() || "Its name"}</Span>
|
||||
{has_picture && hue ? <BotStone hue={hue} size={18} label={`${hue_title} bot`} /> : null}
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate">
|
||||
{name.trim() || "Its name"}
|
||||
</Span>
|
||||
{has_picture && color ? (
|
||||
<BotStone
|
||||
color={color}
|
||||
stone={stone}
|
||||
size={18}
|
||||
label={`${color_title} bot`}
|
||||
/>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{role.trim() || "Its job"}
|
||||
</Span>
|
||||
{hue_title ? (
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">{hue_title}</Span>
|
||||
{color_title ? (
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
{color_title}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Row>
|
||||
|
||||
@@ -6,14 +6,15 @@ import CodeEditor from "@/src/components/general/code-editor";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import ImagePickField from "@/src/components/general/image-pick-field";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotStoneShape } from "@/src/dict/bot-dict";
|
||||
import setProfileImage from "@/src/functions/frontend/set-profile-image";
|
||||
import postAdminApi from "../(functions)/post-admin-api";
|
||||
import usePickedImage from "../(hooks)/use-picked-image";
|
||||
import pickFreeHue from "@/src/utils/pick-free-hue";
|
||||
import { resolveBotColor, resolveBotStone } from "@/src/utils/bot-color";
|
||||
import pickFreeColor from "@/src/utils/pick-free-color";
|
||||
import AddFormActions from "./add-form-actions";
|
||||
import BotColourPicker from "./bot-colour-picker";
|
||||
import BotFormPreview from "./bot-form-preview";
|
||||
import BotHuePicker from "./bot-hue-picker";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
@@ -46,10 +47,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
||||
|
||||
const [name, setName] = useState(bot?.name || "");
|
||||
const [role, setRole] = useState(bot?.role || "");
|
||||
const [hue, setHue] = useState<BotHue>(
|
||||
/** The colour and stone a new bot starts with: the palette's next free one. */
|
||||
const [color, setColor] = useState(
|
||||
() =>
|
||||
(bot?.hue as BotHue) ||
|
||||
pickFreeHue({ taken: crew.map((b) => b.hue) }),
|
||||
resolveBotColor({ color: bot?.color, hue: bot?.hue }) ||
|
||||
pickFreeColor({ taken: crew.map((b) => b.color) }).color,
|
||||
);
|
||||
const [stone, setStone] = useState<BotStoneShape>(() =>
|
||||
resolveBotStone({ stone: bot?.stone, hue: bot?.hue }),
|
||||
);
|
||||
const [system_prompt, setSystemPrompt] = useState(bot?.system_prompt || "");
|
||||
const [join, setJoin] = useState(!bot && Boolean(workspace?.id));
|
||||
@@ -79,13 +84,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
||||
bot_id: bot.id,
|
||||
name: name || bot.name,
|
||||
role,
|
||||
hue,
|
||||
color,
|
||||
stone,
|
||||
system_prompt,
|
||||
},
|
||||
})
|
||||
: await postAdminApi<{ bot_id: number }>({
|
||||
path: "bots/create-bot",
|
||||
body: { name, role, hue, system_prompt, workspace_id },
|
||||
body: { name, role, color, stone, system_prompt, workspace_id },
|
||||
});
|
||||
const bot_id =
|
||||
bot?.id ||
|
||||
@@ -148,7 +154,8 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
||||
<BotFormPreview
|
||||
name={name}
|
||||
role={role}
|
||||
hue={is_coordinator ? null : hue}
|
||||
color={is_coordinator ? null : color}
|
||||
stone={stone}
|
||||
image_media_id={image.image_media_id}
|
||||
picked_src={image.data_url}
|
||||
/>
|
||||
@@ -182,10 +189,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
||||
info="One line, shown under its name."
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
/>
|
||||
<BotHuePicker
|
||||
hue={hue}
|
||||
<BotColourPicker
|
||||
color={color}
|
||||
stone={stone}
|
||||
disabled={loading}
|
||||
onChange={setHue}
|
||||
onChange={({ color: picked, stone: picked_stone }) => {
|
||||
setColor(picked);
|
||||
setStone(picked_stone);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
|
||||
import BotHueOption from "./bot-hue-option";
|
||||
|
||||
type Props = {
|
||||
hue: BotHue;
|
||||
disabled?: boolean;
|
||||
onChange: (hue: BotHue) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks a bot's colour from tiles that show each hue on a stone.
|
||||
*/
|
||||
export default function BotHuePicker({ hue, disabled, onChange }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-2!">
|
||||
<Span className="text-xs text-ink-secondary">Colour</Span>
|
||||
<Row
|
||||
role="radiogroup"
|
||||
aria-label="Colour"
|
||||
className="w-full grid grid-cols-4 sm:grid-cols-7 gap-2!"
|
||||
>
|
||||
{BotHues.map((option) => (
|
||||
<BotHueOption
|
||||
key={option.value}
|
||||
{...option}
|
||||
selected={hue == option.value}
|
||||
disabled={disabled}
|
||||
onSelect={() => onChange(option.value)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
<Span className="text-xs text-ink-secondary">
|
||||
Its colour for life: how you tell it apart, even with a picture.
|
||||
</Span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import messagePreview from "@/src/utils/message-preview";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import UnreadDot from "./unread-dot";
|
||||
@@ -31,7 +32,7 @@ type Props = {
|
||||
* RailBotCard, which also holds the sessions.
|
||||
*/
|
||||
export default function BotRow({ bot, href, selected, subtitle }: Props) {
|
||||
const hue = (bot.hue || null) as BotHue | null;
|
||||
const stone_props = botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue });
|
||||
const snippet =
|
||||
bot.status_line ||
|
||||
subtitle ||
|
||||
@@ -52,7 +53,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
|
||||
selected && "hover:bg-transparent hover:dark:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
|
||||
<BotAvatar image_media_id={bot.image_media_id} {...stone_props} face={(bot.face || "idle") as BotFace} />
|
||||
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{bot.name}
|
||||
@@ -67,7 +68,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
|
||||
{relativeTime({ at: Number(at) })}
|
||||
</Span>
|
||||
) : null}
|
||||
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
|
||||
{bot.unread && stone_props.color ? <UnreadDot color={stone_props.color} /> : null}
|
||||
</Stack>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@ import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
@@ -21,7 +22,7 @@ export default function LinkBotRow({ bot, linking, disabled, onLink }: Props) {
|
||||
<Row className="h-14 items-center gap-3 rounded-default px-3 flex-nowrap hover:bg-sunken transition-colors duration-[var(--m-fast)]">
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
<Stack className="grow min-w-0 gap-0!">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
@@ -13,7 +14,7 @@ export default function RailSeeAllStone({ bot }: Props) {
|
||||
return (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={18}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,8 @@ import ComputerIconTile from "@/src/components/general/computer-icon-tile";
|
||||
import UserAvatar from "@/src/components/general/user-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import {
|
||||
SettingsRecordBack,
|
||||
SettingsRecordSections,
|
||||
@@ -91,7 +92,7 @@ export default function RailSettingsAside({ pathname }: Props) {
|
||||
avatar = (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotHueBgClasses } from "@/src/dict/bot-hue-classes-dict";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The bot's colour (`#rrggbb`), painted straight onto the dot: a free
|
||||
* colour has no token to point at, so it's a fill attribute here rather
|
||||
* than a `bg-*` class.
|
||||
*/
|
||||
color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 6px unread dot in the bot's own hue. Redundant by design (the snippet and
|
||||
* time carry the same information), which is why it may fail 3:1 on some
|
||||
* hues. Never remove the snippet or time.
|
||||
* The unread marker on a rail row. It keeps the bot's colour rather than
|
||||
* an accent, and it never replaces the snippet or time.
|
||||
*/
|
||||
export default function UnreadDot({ hue }: { hue: BotHue }) {
|
||||
export default function UnreadDot({ color }: Props) {
|
||||
return (
|
||||
<Span
|
||||
className={twMerge("size-1.5 rounded-full mr-1", BotHueBgClasses[hue])}
|
||||
aria-label="Unread"
|
||||
role="img"
|
||||
/>
|
||||
<Span className="shrink-0" aria-label="Unread" role="img">
|
||||
<svg width={6} height={6} viewBox="0 0 6 6" aria-hidden="true">
|
||||
<circle cx={3} cy={3} r={3} fill={color} />
|
||||
</svg>
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import BotMessageLine from "./bot-message-line";
|
||||
@@ -20,7 +21,7 @@ export default function BotMessageGroup({ bot, group }: Props) {
|
||||
<Row className="w-full gap-3! flex-nowrap items-start">
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={28}
|
||||
className="-mt-1"
|
||||
|
||||
@@ -6,7 +6,8 @@ import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
@@ -35,7 +36,7 @@ export default function BotProfileModal({ bot }: Props) {
|
||||
const avatar = (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={35}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useContext } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function BotRefChip({ bot_id }: Props) {
|
||||
>
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={18}
|
||||
|
||||
@@ -2,7 +2,8 @@ import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import {
|
||||
BotComputerBusyStatusLine,
|
||||
BotGettingMessageStatusLine,
|
||||
@@ -80,7 +81,7 @@ export default function BotThinkingRow({
|
||||
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={waiting ? "asleep" : "working"}
|
||||
size={28}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
|
||||
@@ -30,7 +31,7 @@ export default function ComposerMentionChip({ node, selected }: ReactNodeViewPro
|
||||
{bot ? (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={18}
|
||||
|
||||
@@ -2,7 +2,8 @@ import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { ComposerMentionItem } from "../(utils)/search-mention-items";
|
||||
|
||||
type Props = {
|
||||
@@ -35,7 +36,7 @@ export default function ComposerMentionOption({ item, active, onPick }: Props) {
|
||||
{bot ? (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={22}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
@@ -29,7 +30,7 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={cron_job.bot_image_media_id}
|
||||
hue={(cron_job.bot_kind == "coordinator" ? null : cron_job.bot_hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: cron_job.bot_color, stone: cron_job.bot_stone, hue: cron_job.bot_hue })}
|
||||
face={(cron_job.bot_face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
@@ -23,7 +24,7 @@ export default function BotSettingsRow({ bot, busy, onDelete }: Props) {
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
|
||||
@@ -29,7 +30,7 @@ export default function BotOverviewGroup({ bot }: Props) {
|
||||
<Row className="w-full gap-4! flex-nowrap items-center">
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotWithFace } from "@/src/types";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import SettingsListRow from "../../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../../(partials)/settings-row-action";
|
||||
|
||||
@@ -30,7 +31,7 @@ export default function ComputerBotRow({
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
@@ -37,7 +38,11 @@ export default function UserAccessGroups({ person, crew_bots, crew_workspaces, o
|
||||
subtitle: bot.role,
|
||||
href: `/admin/settings/bots/${bot.id}`,
|
||||
avatar: (
|
||||
<BotAvatar image_media_id={bot.image_media_id} hue={(bot.hue || null) as BotHue | null} face="idle" />
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face="idle"
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
onUpdated={onUpdated}
|
||||
|
||||
@@ -5,7 +5,8 @@ import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageSquare, NotebookPen, X } from "lucide-react";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
@@ -47,7 +48,7 @@ export default function WorkspaceBotRow({
|
||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
<Link
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import type { WorkspaceActivityItem } from "@/src/types";
|
||||
import messagePreview from "@/src/utils/message-preview";
|
||||
@@ -26,7 +27,7 @@ export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={item.bot_image_media_id}
|
||||
hue={(item.bot_hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: item.bot_color, stone: item.bot_stone, hue: item.bot_hue })}
|
||||
face={(item.bot_face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
|
||||
@@ -22,7 +23,7 @@ export default function WorkspaceDashboardBotRow({ workspace_id, bot }: Props) {
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -33,13 +33,13 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<{ bot_id: number }
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { name, role, hue, system_prompt, workspace_id } = parseWithSchema({ schema: CreateBotRequestSchema, data: body });
|
||||
const { name, role, color, stone, system_prompt, workspace_id } = parseWithSchema({ schema: CreateBotRequestSchema, data: body });
|
||||
|
||||
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
const bot_id = await createBot({ user_id: user.id, name, role, hue, system_prompt });
|
||||
const bot_id = await createBot({ user_id: user.id, name, role, color, stone, system_prompt });
|
||||
|
||||
if (workspace_id) {
|
||||
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
|
||||
|
||||
@@ -13,8 +13,8 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Saves one of the session user's bots (name, job, hue, instructions).
|
||||
* Returns the bot.
|
||||
* Saves one of the session user's bots (name, job, colour, stone,
|
||||
* instructions). Returns the bot.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_BOTS>> = async ({ req, body }) => {
|
||||
try {
|
||||
@@ -32,12 +32,12 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, name, role, hue, system_prompt } = parseWithSchema({
|
||||
const { bot_id, name, role, color, stone, system_prompt } = parseWithSchema({
|
||||
schema: UpdateBotRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const bot = await updateBot({ user_id: user.id, bot_id, name, role, hue, system_prompt });
|
||||
const bot = await updateBot({ user_id: user.id, bot_id, name, role, color, stone, system_prompt });
|
||||
|
||||
await logEvent({
|
||||
title: "Bot changed",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import {
|
||||
BotHues,
|
||||
BotMessageReactions,
|
||||
type BotHue,
|
||||
BotStoneShapes,
|
||||
type BotMessageReactionEmoji,
|
||||
type BotStoneShape,
|
||||
} from "@/src/dict/bot-dict";
|
||||
|
||||
/**
|
||||
@@ -115,7 +115,18 @@ export type ListBotSessionsRequest = z.infer<typeof ListBotSessionsRequestSchema
|
||||
export const CreateBotRequestSchema = z.object({
|
||||
name: z.string().trim().min(1, "Give the bot a name").max(40, "Keep the name under 40 characters"),
|
||||
role: z.string().trim().max(120, "Keep the role to one line").default(""),
|
||||
hue: z.enum(BotHues.map((h) => h.value) as [BotHue, ...BotHue[]]),
|
||||
/**
|
||||
* Its identity colour, `#rrggbb`.
|
||||
*/
|
||||
color: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^#[0-9a-f]{6}$/, "Pick a colour like #7c5cd6"),
|
||||
/**
|
||||
* Which flat-cut stone it's drawn on.
|
||||
*/
|
||||
stone: z.enum(BotStoneShapes.map((s) => s.value) as [BotStoneShape, ...BotStoneShape[]]),
|
||||
system_prompt: z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import { paletteColor } from "@/src/utils/bot-color";
|
||||
|
||||
/**
|
||||
* One-off: bots used to carry a palette `hue`, which decided both their
|
||||
* colour and their stone shape. They now store a free `color` (`#rrggbb`)
|
||||
* and their own `stone`, so a bot keeps the shape it always had while the
|
||||
* user can pick any colour. This fills both from the old hue.
|
||||
*
|
||||
* Safe to run more than once: a bot that already has a colour and a stone
|
||||
* is left alone. Run it with
|
||||
* `bun src/scripts/move-bot-colour-to-owners.ts`, then it can be deleted
|
||||
* along with the `hue` column.
|
||||
*/
|
||||
async function moveBotColourToOwners() {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT id, kind, hue, color, stone FROM bots`,
|
||||
});
|
||||
|
||||
const bots = res.payload || [];
|
||||
let moved = 0;
|
||||
|
||||
for (const bot of bots) {
|
||||
if (!bot.id) continue;
|
||||
|
||||
/** The coordinator stays hue-less, whatever it carried. */
|
||||
if (bot.kind == "coordinator") {
|
||||
if (bot.color || bot.stone || bot.hue) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots SET color = NULL, stone = 'ember', hue = NULL, updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), bot.id],
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bot with a colour has been through this already. A bot without
|
||||
* one hasn't, so its `stone` is only the column's default and its
|
||||
* hue is the truth: both come from there.
|
||||
*/
|
||||
if (bot.color) continue;
|
||||
|
||||
const color = paletteColor({ hue: bot.hue }) || paletteColor({ hue: "ember" })!;
|
||||
const stone = bot.hue || "ember";
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots SET color = ?, stone = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [color, stone, Date.now(), bot.id],
|
||||
});
|
||||
|
||||
moved++;
|
||||
}
|
||||
|
||||
console.log(`Done: ${moved} bot(s) given a colour and a stone, ${bots.length} checked.`);
|
||||
}
|
||||
|
||||
await moveBotColourToOwners();
|
||||
process.exit(0);
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
BotMessageAuthor,
|
||||
BotTaskStepState,
|
||||
} from "@/src/dict/bot-dict";
|
||||
import { paletteColor } from "@/src/utils/bot-color";
|
||||
import setBotFace from "@/src/functions/backend/db/bots/set-bot-face";
|
||||
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
|
||||
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
|
||||
@@ -49,6 +50,10 @@ type SeedTask = {
|
||||
|
||||
type SeedBot = {
|
||||
name: string;
|
||||
/**
|
||||
* The palette entry it's drawn with, which gives it both its colour and
|
||||
* its stone. Null for the coordinator.
|
||||
*/
|
||||
hue: BotHue | null;
|
||||
role: string | null;
|
||||
face: BotFace;
|
||||
@@ -221,7 +226,8 @@ async function insertBot({
|
||||
user_id,
|
||||
name: bot.name,
|
||||
kind: bot.hue ? "worker" : "coordinator",
|
||||
hue: bot.hue || undefined,
|
||||
color: paletteColor({ hue: bot.hue }) || undefined,
|
||||
stone: bot.hue || undefined,
|
||||
role: bot.role || undefined,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -83,6 +83,8 @@ export type CronJobWithNames = BUN_SQLITE_COBBLE_CRON_JOBS & {
|
||||
bot_name?: string | null;
|
||||
bot_kind?: string | null;
|
||||
bot_hue?: string | null;
|
||||
bot_color?: string | null;
|
||||
bot_stone?: string | null;
|
||||
bot_face?: string | null;
|
||||
bot_image_media_id?: number | null;
|
||||
workspace_name?: string | null;
|
||||
@@ -96,6 +98,8 @@ export type TaskWithNames = BUN_SQLITE_COBBLE_TASKS & {
|
||||
bot_name?: string | null;
|
||||
bot_kind?: string | null;
|
||||
bot_hue?: string | null;
|
||||
bot_color?: string | null;
|
||||
bot_stone?: string | null;
|
||||
bot_face?: string | null;
|
||||
bot_image_media_id?: number | null;
|
||||
workspace_name?: string | null;
|
||||
@@ -160,6 +164,8 @@ export type WorkspaceActivityItem = BUN_SQLITE_COBBLE_BOT_SESSIONS & {
|
||||
bot_name?: string | null;
|
||||
bot_image_media_id?: number | null;
|
||||
bot_hue?: string | null;
|
||||
bot_color?: string | null;
|
||||
bot_stone?: string | null;
|
||||
bot_face?: string | null;
|
||||
};
|
||||
|
||||
@@ -275,6 +281,12 @@ export type BotUpdatedData = {
|
||||
* change). A workspace's crew drops a bot that's no longer in it.
|
||||
*/
|
||||
workspace_ids?: number[];
|
||||
/**
|
||||
* Its colour and stone, when the push read them (a recolour, or any bot
|
||||
* change): every rail row and avatar takes them without a reload.
|
||||
*/
|
||||
color?: string | null;
|
||||
stone?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
botColorNeedsPaperFace,
|
||||
normalizeBotColor,
|
||||
paletteColor,
|
||||
paletteColorTitle,
|
||||
resolveBotColor,
|
||||
resolveBotStone,
|
||||
} from "./bot-color";
|
||||
|
||||
describe("bot-color", () => {
|
||||
test("normalizes a colour to lowercase #rrggbb", () => {
|
||||
expect(normalizeBotColor({ color: " #E4572E " })).toBe("#e4572e");
|
||||
});
|
||||
|
||||
test("expands the short form", () => {
|
||||
expect(normalizeBotColor({ color: "#7C5" })).toBe("#77cc55");
|
||||
});
|
||||
|
||||
test("refuses anything that isn't a colour", () => {
|
||||
expect(normalizeBotColor({ color: "violet" })).toBeNull();
|
||||
expect(normalizeBotColor({ color: "#12345" })).toBeNull();
|
||||
expect(normalizeBotColor({ color: "" })).toBeNull();
|
||||
expect(normalizeBotColor({ color: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("names a palette colour, and leaves a custom one unnamed", () => {
|
||||
expect(paletteColorTitle({ color: "#7c5cd6" })).toBe("Violet");
|
||||
expect(paletteColorTitle({ color: "#123456" })).toBeNull();
|
||||
});
|
||||
|
||||
test("a bot's own colour wins over its old hue", () => {
|
||||
expect(resolveBotColor({ color: "#123456", hue: "violet" })).toBe("#123456");
|
||||
});
|
||||
|
||||
test("a bot saved before colours were free shows its hue's colour", () => {
|
||||
expect(resolveBotColor({ hue: "teal" })).toBe("#16a394");
|
||||
});
|
||||
|
||||
test("the coordinator has no colour", () => {
|
||||
expect(resolveBotColor({})).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps its own stone, else the shape its hue used to be", () => {
|
||||
expect(resolveBotStone({ stone: "rose", hue: "teal" })).toBe("rose");
|
||||
expect(resolveBotStone({ hue: "teal" })).toBe("teal");
|
||||
expect(resolveBotStone({})).toBe("ember");
|
||||
});
|
||||
|
||||
test("only a very dark colour flips the face to paper", () => {
|
||||
expect(botColorNeedsPaperFace({ color: "#000000" })).toBe(true);
|
||||
expect(botColorNeedsPaperFace({ color: "#0e1116" })).toBe(true);
|
||||
expect(botColorNeedsPaperFace({ color: "#7c5cd6" })).toBe(false);
|
||||
expect(botColorNeedsPaperFace({ color: "#ffffff" })).toBe(false);
|
||||
});
|
||||
|
||||
test("a palette entry knows its own colour", () => {
|
||||
expect(paletteColor({ hue: "ember" })).toBe("#e4572e");
|
||||
expect(paletteColor({ hue: "nonsense" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { BotHues, BotStoneShapes, type BotStoneShape } from "@/src/dict/bot-dict";
|
||||
|
||||
/**
|
||||
* A bot's colour is free (`#rrggbb`) and its stone shape is its own, so the
|
||||
* two are stored apart. These helpers are the only place that decides what a
|
||||
* bot actually renders in: the colour to fill its stone with and the shape
|
||||
* to draw it on.
|
||||
*/
|
||||
|
||||
const HexColorPattern = /^#[0-9a-f]{6}$/;
|
||||
|
||||
/**
|
||||
* A stored colour as `#rrggbb`, or null when it isn't one. Accepts the short
|
||||
* `#rgb` form someone typed, so a hand-entered value still saves.
|
||||
*/
|
||||
export function normalizeBotColor({ color }: { color?: string | null }): string | null {
|
||||
const trimmed = (color || "").trim().toLowerCase();
|
||||
|
||||
if (!trimmed) return null;
|
||||
|
||||
const short = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
||||
|
||||
if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
|
||||
|
||||
return HexColorPattern.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hex a palette entry stands for, by its name.
|
||||
*/
|
||||
export function paletteColor({ hue }: { hue?: string | null }): string | null {
|
||||
return BotHues.find((entry) => entry.value == hue)?.color || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour a bot renders in: its own, else the palette colour of a bot
|
||||
* saved before colours were free, else (the coordinator) nothing at all.
|
||||
*/
|
||||
export function resolveBotColor({
|
||||
color,
|
||||
hue,
|
||||
}: {
|
||||
color?: string | null;
|
||||
hue?: string | null;
|
||||
}): string | null {
|
||||
return normalizeBotColor({ color }) || paletteColor({ hue });
|
||||
}
|
||||
|
||||
/**
|
||||
* The stone a bot is drawn on: its own shape, else the palette shape it used
|
||||
* to be tied to, else the first stone.
|
||||
*/
|
||||
export function resolveBotStone({
|
||||
stone,
|
||||
hue,
|
||||
}: {
|
||||
stone?: string | null;
|
||||
hue?: string | null;
|
||||
}): BotStoneShape {
|
||||
const shape = BotStoneShapes.find((entry) => entry.value == stone)?.value;
|
||||
|
||||
if (shape) return shape;
|
||||
|
||||
const carried = BotStoneShapes.find((entry) => entry.value == hue)?.value;
|
||||
|
||||
return carried || BotStoneShapes[0].value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative luminance of a `#rrggbb` colour, 0 (black) to 1 (white), so the
|
||||
* stone's face can be flipped to paper on a dark body and stays readable.
|
||||
*/
|
||||
export function botColorLuminance({ color }: { color: string }): number {
|
||||
const hex = normalizeBotColor({ color }) || "#ffffff";
|
||||
const [red, green, blue] = [1, 3, 5].map((at) => {
|
||||
const value = parseInt(hex.slice(at, at + 2), 16) / 255;
|
||||
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
|
||||
return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stone's eyes must be drawn in paper instead of ink. Only a
|
||||
* very dark body needs it, so a mid-tone keeps the ink face every other
|
||||
* stone has.
|
||||
*/
|
||||
export function botColorNeedsPaperFace({ color }: { color: string }): boolean {
|
||||
return botColorLuminance({ color }) < 0.12;
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette name a colour is, for a label ("Violet"); null for a colour
|
||||
* the user made up, which the UI shows as a custom one.
|
||||
*/
|
||||
export function paletteColorTitle({ color }: { color?: string | null }): string | null {
|
||||
const normalized = normalizeBotColor({ color });
|
||||
|
||||
if (!normalized) return null;
|
||||
|
||||
return BotHues.find((entry) => entry.color == normalized)?.title || null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BotStoneShape } from "@/src/dict/bot-dict";
|
||||
import { resolveBotColor, resolveBotStone } from "./bot-color";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The bot's own colour. Null or missing for the coordinator.
|
||||
*/
|
||||
color?: string | null;
|
||||
stone?: string | null;
|
||||
/**
|
||||
* The historical palette hue, only for a bot saved before colours were
|
||||
* free. Rows from otherwise-typed queries select it as `bot_hue`.
|
||||
*/
|
||||
hue?: string | null;
|
||||
};
|
||||
|
||||
type BotStoneProps = {
|
||||
color: string | null;
|
||||
stone: BotStoneShape;
|
||||
};
|
||||
|
||||
/**
|
||||
* The stone props for a bot row: which colour to fill it with and which
|
||||
* shape to draw, whether the row carries the new columns or only the
|
||||
* palette hue they replaced. Every `<BotAvatar color={...} stone={...} />`
|
||||
* call site goes through this, so a row saved before colours were free
|
||||
* still renders as it did.
|
||||
*/
|
||||
export default function botStoneProps({ color, stone, hue }: Params): BotStoneProps {
|
||||
return {
|
||||
color: resolveBotColor({ color, hue }),
|
||||
stone: resolveBotStone({ stone, hue }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import pickFreeColor from "./pick-free-color";
|
||||
|
||||
describe("pick-free-color", () => {
|
||||
test("takes the first palette colour nobody has", () => {
|
||||
expect(pickFreeColor({ taken: [] })).toEqual({ color: "#e4572e", stone: "ember" });
|
||||
});
|
||||
|
||||
test("skips the colours already taken", () => {
|
||||
expect(pickFreeColor({ taken: ["#e4572e", "#e9a23b", null] })).toEqual({
|
||||
color: "#7fae3c",
|
||||
stone: "moss",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to the least used colour", () => {
|
||||
const everyone = ["#e4572e", "#e9a23b", "#7fae3c", "#16a394", "#3b7dd8", "#7c5cd6"];
|
||||
|
||||
expect(pickFreeColor({ taken: [...everyone, "#e4572e"] })).toEqual({
|
||||
color: "#d6538c",
|
||||
stone: "rose",
|
||||
});
|
||||
});
|
||||
|
||||
test("counts a colour however it's cased", () => {
|
||||
expect(pickFreeColor({ taken: ["#E4572E"] }).color).toBe("#e9a23b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BotHues, type BotStoneShape } from "@/src/dict/bot-dict";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The colours already in use, so the new bot differs from the crew.
|
||||
*/
|
||||
taken: (string | null | undefined)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The colour a new bot starts with: the first palette colour nobody on the
|
||||
* crew has yet, else the least used one. It comes with that palette entry's
|
||||
* stone, so a new bot looks like every bot did before colours were free.
|
||||
* The user can then pick any colour they like.
|
||||
*/
|
||||
export default function pickFreeColor({ taken }: Params): {
|
||||
color: string;
|
||||
stone: BotStoneShape;
|
||||
} {
|
||||
const counts = new Map<string, number>(BotHues.map((entry) => [entry.color, 0]));
|
||||
|
||||
for (const color of taken) {
|
||||
const key = (color || "").toLowerCase();
|
||||
|
||||
if (counts.has(key)) counts.set(key, counts.get(key)! + 1);
|
||||
}
|
||||
|
||||
const picked = [...BotHues].sort(
|
||||
(a, b) => counts.get(a.color)! - counts.get(b.color)!,
|
||||
)[0]!;
|
||||
|
||||
return { color: picked.color, stone: picked.value };
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import pickFreeHue from "./pick-free-hue";
|
||||
|
||||
describe("pickFreeHue", () => {
|
||||
test("takes the first hue nobody has", () => {
|
||||
expect(pickFreeHue({ taken: [] })).toBe("ember");
|
||||
expect(pickFreeHue({ taken: ["ember", null, "amber"] })).toBe("moss");
|
||||
});
|
||||
|
||||
test("falls back to the least used hue", () => {
|
||||
const all = ["ember", "amber", "moss", "teal", "cobalt", "violet", "rose"];
|
||||
expect(pickFreeHue({ taken: [...all, ...all.filter((h) => h != "teal")] })).toBe("teal");
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Hues the crew already has.
|
||||
*/
|
||||
taken: (string | null | undefined)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The first hue nobody on the crew has yet, else the least used one, so a
|
||||
* new bot is told apart at a glance.
|
||||
*/
|
||||
export default function pickFreeHue({ taken }: Params): BotHue {
|
||||
const counts = new Map<string, number>(BotHues.map((hue) => [hue.value, 0]));
|
||||
|
||||
for (const hue of taken) {
|
||||
if (hue && counts.has(hue)) counts.set(hue, counts.get(hue)! + 1);
|
||||
}
|
||||
|
||||
return [...BotHues].sort((a, b) => counts.get(a.value)! - counts.get(b.value)!)[0]!.value;
|
||||
}
|
||||
Reference in New Issue
Block a user