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:
2026-10-22 11:55:36 +01:00
parent 2eccfe9e13
commit 1a6249afd3
59 changed files with 877 additions and 237 deletions
@@ -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`,
+12 -4
View File
@@ -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,
},
+23 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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