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
+7 -5
View File
@@ -4,7 +4,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w
**The handoff is outdated.** `assets/handoff/` (gitignored locally: `PRODUCT.md`, `IMPLEMENTATION.md`, `tokens/`, `assets/`) is the *foundation*, not the design: it is what the project started from, and the app has moved well past it. Its layouts, screens (`assets/screens/*.png`), right-hand "working on" panel, and PRODUCT/IMPLEMENTATION briefs describe an early single-chat prototype and must not be used as reference for the current UI or copied into docs, screenshots or marketing. What is still authoritative in it: **The handoff is outdated.** `assets/handoff/` (gitignored locally: `PRODUCT.md`, `IMPLEMENTATION.md`, `tokens/`, `assets/`) is the *foundation*, not the design: it is what the project started from, and the app has moved well past it. Its layouts, screens (`assets/screens/*.png`), right-hand "working on" panel, and PRODUCT/IMPLEMENTATION briefs describe an early single-chat prototype and must not be used as reference for the current UI or copied into docs, screenshots or marketing. What is still authoritative in it:
- **Brand assets:** the stone icons (`public/brand/icons/*.svg`, mirrored into `src/data/stone-geometry.ts`), the coordinator (`public/brand/coordinator/`), the app icon (`public/brand/app-icon/`), and the wordmark (`public/brand/wordmark/`, mirrored into `src/data/wordmark-paths.ts`). These are the identity and do not change; the mark is a stone with exactly one flat cut, hue is a bot's identity (never status), the coordinator is hue-less. - **Brand assets:** the stone icons (`public/brand/icons/*.svg`, mirrored into `src/data/stone-geometry.ts`), the coordinator (`public/brand/coordinator/`), the app icon (`public/brand/app-icon/`), and the wordmark (`public/brand/wordmark/`, mirrored into `src/data/wordmark-paths.ts`). These are the identity and do not change; the mark is a stone with exactly one flat cut, a bot's colour is its identity (never status), the coordinator is hue-less.
- **Tokens:** `tokens/tokens.css` was the origin of the `:root` / `.dark` block in `src/styles/main.css`. From here on `src/styles/main.css` is the source of truth; treat the handoff CSS as historical. - **Tokens:** `tokens/tokens.css` was the origin of the `:root` / `.dark` block in `src/styles/main.css`. From here on `src/styles/main.css` is the source of truth; treat the handoff CSS as historical.
For the current UI, read the code: `src/layouts/bots/` (rail, workspace rail, user popover), `src/pages/admin/bots/[bot_id]/` (conversation header, thread, composer, modals) and `src/pages/admin/workspaces/` (dashboard). When the handoff and the current code or the user disagree, the code and the user win, always. For the current UI, read the code: `src/layouts/bots/` (rail, workspace rail, user popover), `src/pages/admin/bots/[bot_id]/` (conversation header, thread, composer, modals) and `src/pages/admin/workspaces/` (dashboard). When the handoff and the current code or the user disagree, the code and the user win, always.
@@ -95,7 +95,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
### Bots (the chat shell) ### Bots (the chat shell)
- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only to `canManageCrew`. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. - **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only to `canManageCrew`. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this.
- **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - **Tables:** `bots` (`kind` worker|coordinator, `color` (a free `#rrggbb` the user picks) and `stone` (which flat-cut shape it's drawn on), `role`, `system_prompt` (the bot's core instructions, markdown); `hue` is the palette column they replaced, read only as a fallback), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
- **Routes:** `/admin/bots` opens the conversation this browser was last in (`readLastVisitedBot` from the `cobble_last_visited_bot_id` / `..._workspace_id` cookies, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears), but only while `grabReachableConversation` says the user still reaches it. Otherwise it ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. - **Routes:** `/admin/bots` opens the conversation this browser was last in (`readLastVisitedBot` from the `cobble_last_visited_bot_id` / `..._workspace_id` cookies, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears), but only while `grabReachableConversation` says the user still reaches it. Otherwise it ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
@@ -105,8 +105,9 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one). - **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one).
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; a message still on its way to the server reads "<name> is getting your message…" (`BotGettingMessageStatusLine`), while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. - **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; a message still on its way to the server reads "<name> is getting your message…" (`BotGettingMessageStatusLine`), while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles. - **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles.
- **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`, the old one deleted; served at `profileImageUrls`). `BotAvatar` shows a bot's picture as a rounded square, else its stone; its hue still applies. `WorkspaceAvatar` shows a workspace's, else the workspace icon. A bot is edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less). - **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`, the old one deleted; served at `profileImageUrls`). `BotAvatar` shows a bot's picture as a rounded square, else its stone; its colour still applies. `WorkspaceAvatar` shows a workspace's, else the workspace icon. A bot is edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less).
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text. - **Colour:** a bot's colour is free. It lives in `bots.color` (`#rrggbb`, set from the palette tiles, the native swatch or a typed hex in `(partials)/bot-colour-picker.tsx`, `api/admin/bots/`), and the stone it's drawn on is `bots.stone` (`BotStoneShapes`), because shape no longer follows colour. `src/utils/bot-color.ts` is the only place that resolves them (`resolveBotColor`/`resolveBotStone` fall back to the old `hue` for a bot saved before this, `botColorNeedsPaperFace` flips the eyes to paper on a very dark body), and `src/utils/bot-stone-props.ts` gives every row its `color`/`stone` props. A new bot starts on the palette's next free colour (`src/utils/pick-free-color.ts`). `src/scripts/move-bot-colour-to-owners.ts` backfills an install from `hue` (safe to rerun).
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, the bot's colour as the body's SVG `fill` attribute, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text.
- **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only). - **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only).
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then the install-wide `# Install-wide instructions` (`bot_system_prompt_append` in `app_settings`, set by a super admin on Settings > General, `grabSystemPromptAppend`; empty when unset), then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's. - **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then the install-wide `# Install-wide instructions` (`bot_system_prompt_append` in `app_settings`, set by a super admin on Settings > General, `grabSystemPromptAppend`; empty when unset), then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's.
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you for the job's user), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. One attempt has a deadline (`AppData.BotJobAttemptTimeoutMs`): the heartbeat keeps the lock alive for as long as the run takes, so a run past the deadline is aborted, reported as a failed try and retried, and a model or tool that never answers can't hold its slot, conversation or computer forever. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`; other tools in words, an MCP tool as "Using Exa search (web search exa)"). - **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you for the job's user), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. One attempt has a deadline (`AppData.BotJobAttemptTimeoutMs`): the heartbeat keeps the lock alive for as long as the run takes, so a run past the deadline is aborted, reported as a failed try and retried, and a model or tool that never answers can't hold its slot, conversation or computer forever. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`; other tools in words, an MCP tool as "Using Exa search (web search exa)").
@@ -262,7 +263,8 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
- Shared: `Logo`, `LogoIcon`, `InnerPageHeroSection`, `AdminHero`, `LoginShell` (auth pages/forms), `TaglineSection` - Shared: `Logo`, `LogoIcon`, `InnerPageHeroSection`, `AdminHero`, `LoginShell` (auth pages/forms), `TaglineSection`
- **UNCHANGEABLE RULE - twui components always win over raw JSX elements.** Use twui components (`H1`-`H5`, `P`, `Span`, `List`, `Img`, `Row`, `Stack`, `Container`, `Section`, `Card`, `Button`, `Tag`, `Link`, etc.) before basic JSX elements (`<h1>`-`<h6>`, `<p>`, `<span>`, `<ul>`, `<li>`, `<img>`, `<a>`, `<button>`, `<div>`-as-card, etc.). Never introduce or reintroduce a raw JSX element where a twui equivalent exists. Override twui defaults via `className` rather than reaching for the raw element. - **UNCHANGEABLE RULE - twui components always win over raw JSX elements.** Use twui components (`H1`-`H5`, `P`, `Span`, `List`, `Img`, `Row`, `Stack`, `Container`, `Section`, `Card`, `Button`, `Tag`, `Link`, etc.) before basic JSX elements (`<h1>`-`<h6>`, `<p>`, `<span>`, `<ul>`, `<li>`, `<img>`, `<a>`, `<button>`, `<div>`-as-card, etc.). Never introduce or reintroduce a raw JSX element where a twui equivalent exists. Override twui defaults via `className` rather than reaching for the raw element.
- **Never create new CSS classes, CSS files, or inline `style={{...}}` hacks.** Style exclusively by composing existing component props/classNames, existing twui classNames (`twui-*`), semantic theme-token utilities (`bg-dark`, `bg-light`, `text-dark`, `text-primary`, `bg-primary`, `text-accent`, `bg-accent`, `text-secondary`, `border-slate-200`, etc.), and utility classes already used elsewhere in this codebase. Build up from what exists; extend component code only when the existing API cannot express the need. - **Never create new CSS classes, CSS files, or inline `style={{...}}` hacks.** Style exclusively by composing existing component props/classNames, existing twui classNames (`twui-*`), semantic theme-token utilities (`bg-dark`, `bg-light`, `text-dark`, `text-primary`, `bg-primary`, `text-accent`, `bg-accent`, `text-secondary`, `border-slate-200`, etc.), and utility classes already used elsewhere in this codebase. Build up from what exists; extend component code only when the existing API cannot express the need.
- **Theming:** colors, radii, and fonts change only in `src/styles/main.css`: Cobble tokens as CSS vars on `:root` (light) and `.dark`, exposed in the `@theme inline` block as `paper`, `surface`, `sunken`, `ink`, `ink-secondary`, `ink-disabled`, `hairline`, `hairline-strong`, `on-ink`, `hue-<name>`, plus `shadow-popover` and `--m-fast/base/slow` motion vars (zeroed under reduced motion). Font is Lato (stand-in). Never hardcode hex colors, radii, or font stacks inside components. Hues are bot identity only, never state; use `src/dict/bot-hue-classes-dict.ts` for hue classes. - **Theming:** colors, radii, and fonts change only in `src/styles/main.css`: Cobble tokens as CSS vars on `:root` (light) and `.dark`, exposed in the `@theme inline` block as `paper`, `surface`, `sunken`, `ink`, `ink-secondary`, `ink-disabled`, `hairline`, `hairline-strong`, `on-ink`, `hue-<name>`, plus `shadow-popover` and `--m-fast/base/slow` motion vars (zeroed under reduced motion). Font is Lato (stand-in). Never hardcode hex colors, radii, or font stacks inside components. Hues are bot identity only, never state, and the `--color-hue-*` tokens remain for the fixed palettes that use them (the files browser's volume icons, `BotHues`' own hexes).
- **The one exception:** a bot's colour is user data, not a theme value, so it reaches `BotStone` (`src/components/general/bot-stone.tsx`) and `UnreadDot` as an SVG `fill` attribute - an arbitrary hex has no token to point at. Nothing else in the app may take a colour that way, and `resolveBotColor`/`normalizeBotColor` (`src/utils/bot-color.ts`) are the only place a colour string is validated.
- **Branding:** `docs/readme/hero-light.png`, `hero-dark.png`, `workspace-light.png` and `workspace-dark.png` are screenshots of the current app (chat and workspace dashboard, 1440px at 2x, downscaled to 1600px wide), committed and picked per theme by GitHub's `#gh-light-mode-only` / `#gh-dark-mode-only` fragment. They are not generated: when the UI changes, retake them from a seeded dev instance (`bun run seed`, `bun run seed:bots`) and keep the same names, so the README never shows an older dashboard. Never use `assets/handoff/assets/screens/*.png` for anything: that design is outdated. `public/logos/logo.svg` is the single source of truth for the logo mark; `logo-icon.tsx` renders it inline (via `BotStone`) and `logo.tsx` renders the stone beside the vector `Wordmark`. Whenever the logo changes, run `bun run gen:favicon` to regenerate `public/favicon.ico`, `public/favicon.png`, `public/apple-touch-icon.png`, and `public/logos/logo.png` (the last is used in emails). Font + favicon links live in `__root.tsx` `Head()`. - **Branding:** `docs/readme/hero-light.png`, `hero-dark.png`, `workspace-light.png` and `workspace-dark.png` are screenshots of the current app (chat and workspace dashboard, 1440px at 2x, downscaled to 1600px wide), committed and picked per theme by GitHub's `#gh-light-mode-only` / `#gh-dark-mode-only` fragment. They are not generated: when the UI changes, retake them from a seeded dev instance (`bun run seed`, `bun run seed:bots`) and keep the same names, so the README never shows an older dashboard. Never use `assets/handoff/assets/screens/*.png` for anything: that design is outdated. `public/logos/logo.svg` is the single source of truth for the logo mark; `logo-icon.tsx` renders it inline (via `BotStone`) and `logo.tsx` renders the stone beside the vector `Wordmark`. Whenever the logo changes, run `bun run gen:favicon` to regenerate `public/favicon.ico`, `public/favicon.png`, `public/apple-touch-icon.png`, and `public/logos/logo.png` (the last is used in emails). Font + favicon links live in `__root.tsx` `Head()`.
- **Site name:** brand literals live only in `src/data/site-data.ts`. Page meta titles, emails, and UI copy must interpolate `SiteData["SiteName"]` - never hardcode a brand name in page files. - **Site name:** brand literals live only in `src/data/site-data.ts`. Page meta titles, emails, and UI copy must interpolate `SiteData["SiteName"]` - never hardcode a brand name in page files.
+14 -1
View File
@@ -6,6 +6,7 @@ import {
BotKinds, BotKinds,
BotMessageAuthors, BotMessageAuthors,
BotSessionStatuses, BotSessionStatuses,
BotStoneShapes,
BotTaskStatuses, BotTaskStatuses,
BotTaskStepStates, BotTaskStepStates,
} from "@/src/dict/bot-dict"; } from "@/src/dict/bot-dict";
@@ -221,9 +222,21 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
options: BotKinds.map((bk) => bk.value), options: BotKinds.map((bk) => bk.value),
defaultValue: "worker", defaultValue: "worker",
}, },
{
fieldName: "color",
fieldDescription: `The bot's identity colour, a \`#rrggbb\` string. Null for the coordinator, which stays hue-less`,
dataType: "TEXT",
},
{
fieldName: "stone",
fieldDescription: `Which flat-cut stone the bot renders in its colour. The shape no longer follows the colour`,
dataType: "TEXT",
options: BotStoneShapes.map((bs) => bs.value),
defaultValue: "ember",
},
{ {
fieldName: "hue", fieldName: "hue",
fieldDescription: `Permanent identity hue. Null for the coordinator`, fieldDescription: `Historical: the palette hue from before \`color\` and \`stone\`. Only read as a fallback for a bot saved before them, and backfilled by \`move-bot-colour-to-owners\``,
dataType: "TEXT", dataType: "TEXT",
options: BotHues.map((bh) => bh.value), options: BotHues.map((bh) => bh.value),
}, },
+9 -1
View File
@@ -189,7 +189,15 @@ export type BUN_SQLITE_COBBLE_BOTS = {
*/ */
kind?: "worker" | "coordinator" | ""; kind?: "worker" | "coordinator" | "";
/** /**
* Permanent identity hue. Null for the coordinator * The bot's identity colour, a `#rrggbb` string. Null for the coordinator, which stays hue-less
*/
color?: string;
/**
* Which flat-cut stone the bot renders in its colour. The shape no longer follows the colour
*/
stone?: "ember" | "amber" | "moss" | "teal" | "cobalt" | "violet" | "rose" | "";
/**
* Historical: the palette hue from before `color` and `stone`. Only read as a fallback for a bot saved before them, and backfilled by `move-bot-colour-to-owners`
*/ */
hue?: "ember" | "amber" | "moss" | "teal" | "cobalt" | "violet" | "rose" | ""; hue?: "ember" | "amber" | "moss" | "teal" | "cobalt" | "violet" | "rose" | "";
/** /**
+11
View File
@@ -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 grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
import { logError } from "@/src/functions/backend/logger"; import { logError } from "@/src/functions/backend/logger";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task"; import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
@@ -61,6 +63,13 @@ async function push({
workspace_ids, workspace_ids,
}: Params) { }: Params) {
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id }); 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) { for (const viewer_id of audience) {
if (users && !users.has(String(viewer_id))) continue; if (users && !users.has(String(viewer_id))) continue;
@@ -72,6 +81,8 @@ async function push({
data: { data: {
id: Number(bot_id), id: Number(bot_id),
...(await grabBotFace({ bot_id, user_id: viewer_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 } : {}), ...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
...(workspace_ids !== undefined ? { workspace_ids } : {}), ...(workspace_ids !== undefined ? { workspace_ids } : {}),
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }), task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
+1 -1
View File
@@ -29,7 +29,7 @@ type Props = ComponentProps<typeof BotStone> & {
/** /**
* A bot's avatar: its custom picture when it has one (a rounded square, * 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 * 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) { export default function BotAvatar({ image_media_id, src: picked, ...stone }: Props) {
const saved = profileImageUrls({ media_id: image_media_id }); const saved = profileImageUrls({ media_id: image_media_id });
+39 -11
View File
@@ -1,19 +1,35 @@
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { BotFace, BotHue } from "@/src/dict/bot-dict"; import type { BotFace, BotStoneShape } from "@/src/dict/bot-dict";
import { BotHueFillClasses } from "@/src/dict/bot-hue-classes-dict";
import { import {
CoordinatorBodyPath, CoordinatorBodyPath,
StoneBodyPaths, StoneBodyPaths,
StoneViewBox, StoneViewBox,
} from "@/src/data/stone-geometry"; } from "@/src/data/stone-geometry";
import { botColorNeedsPaperFace, resolveBotStone } from "@/src/utils/bot-color";
import BotStoneFace from "./bot-stone-face"; import BotStoneFace from "./bot-stone-face";
type Props = { type Props = {
/** /**
* The bot's identity hue. Omit (or `null`) for the coordinator, which * The bot's identity colour (`#rrggbb`). Omit (or `null`) for the
* renders hue-less: ink body, paper eyes, inverted in dark mode. * 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; face?: BotFace;
size?: number; size?: number;
/** /**
@@ -30,17 +46,25 @@ type Props = {
* eyes. Never put it in a circle, ring or badge, or add an outline or shadow. * eyes. Never put it in a circle, ring or badge, or add an outline or shadow.
*/ */
export default function BotStone({ export default function BotStone({
color,
stone,
hue, hue,
face = "idle", face = "idle",
size = 32, size = 32,
label, label,
className, className,
}: Props) { }: Props) {
const is_coordinator = !hue; const is_coordinator = !color;
const body_path = hue ? StoneBodyPaths[hue] : CoordinatorBodyPath; const body_path = is_coordinator
const body_class = hue ? BotHueFillClasses[hue] : "fill-ink"; ? CoordinatorBodyPath
const eye_fill = is_coordinator ? "fill-paper" : "fill-dark"; : StoneBodyPaths[resolveBotStone({ stone, hue })];
const eye_stroke = is_coordinator ? "stroke-paper" : "stroke-dark"; /**
* 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 ( return (
<svg <svg
@@ -52,7 +76,11 @@ export default function BotStone({
aria-hidden={label ? undefined : true} aria-hidden={label ? undefined : true}
className={twMerge("shrink-0", className)} 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 <BotStoneFace
face={face} face={face}
fill_class={eye_fill} fill_class={eye_fill}
+3 -1
View File
@@ -1,4 +1,5 @@
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { BotHues } from "@/src/dict/bot-dict";
import BotStone from "./bot-stone"; import BotStone from "./bot-stone";
type Props = { type Props = {
@@ -13,7 +14,8 @@ type Props = {
export default function LogoIcon({ icon_size = 40, img_props }: Props) { export default function LogoIcon({ icon_size = 40, img_props }: Props) {
return ( return (
<BotStone <BotStone
hue="ember" color={BotHues[0].color}
stone={BotHues[0].value}
size={icon_size} size={icon_size}
label="Logo Icon" label="Logo Icon"
className={img_props?.className} className={img_props?.className}
+3 -2
View File
@@ -6,7 +6,8 @@ import Button from "@moduletrace/twui/layout/Button";
import type { TaskWithNames } from "@/src/types"; import type { TaskWithNames } from "@/src/types";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time"; 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"> <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 <BotAvatar
image_media_id={task.bot_image_media_id} 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} face={(task.bot_face || "idle") as BotFace}
size={28} size={28}
/> />
+52 -2
View File
@@ -1,40 +1,90 @@
/** /**
* Bot identity hues. A bot keeps its hue for life; a hue never means state. * The palette a bot starts from: a suggested colour and the stone shape that
* The coordinator has no hue. * 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 = [ export const BotHues = [
{ {
title: "Ember", title: "Ember",
value: "ember", value: "ember",
color: "#e4572e",
}, },
{ {
title: "Amber", title: "Amber",
value: "amber", value: "amber",
color: "#e9a23b",
}, },
{ {
title: "Moss", title: "Moss",
value: "moss", value: "moss",
color: "#7fae3c",
}, },
{ {
title: "Teal", title: "Teal",
value: "teal", value: "teal",
color: "#16a394",
}, },
{ {
title: "Cobalt", title: "Cobalt",
value: "cobalt", value: "cobalt",
color: "#3b7dd8",
}, },
{ {
title: "Violet", title: "Violet",
value: "violet", value: "violet",
color: "#7c5cd6",
}, },
{ {
title: "Rose", title: "Rose",
value: "rose", value: "rose",
color: "#d6538c",
}, },
] as const; ] 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"]; 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 = [ export const BotKinds = [
{ {
title: "Worker", title: "Worker",
-24
View File
@@ -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() { export default async function grabCrewOptions() {
const [bots, workspaces] = await Promise.all([ const [bots, workspaces] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({ 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>({ 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`, 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 BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db"; 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 assertPromptLength from "@/src/utils/assert-prompt-length";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager"; import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
import attachDefaultExtensions from "../extensions/attach-default-extensions"; import attachDefaultExtensions from "../extensions/attach-default-extensions";
@@ -12,7 +12,14 @@ type Params = {
user_id: number | string; user_id: number | string;
name: string; name: string;
role?: 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). * 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 * computer of its own until it's given one; in a workspace it uses the
* workspace's. * 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 }); await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt }); 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), user_id: Number(user_id),
name, name,
kind: "worker", kind: "worker",
hue, color,
stone,
role: role || undefined, role: role || undefined,
system_prompt, system_prompt,
}, },
+23 -8
View File
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite"; 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 AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length"; import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot"; import grabUserBot from "./grab-user-bot";
@@ -10,16 +10,31 @@ type Params = {
bot_id: number | string; bot_id: number | string;
name: string; name: string;
role?: string; role?: string;
hue: BotHue; /**
* Its identity colour, `#rrggbb`.
*/
color: string;
/**
* Which flat-cut stone it's drawn on.
*/
stone: BotStoneShape;
system_prompt: string; system_prompt: string;
}; };
/** /**
* Saves one of the user's bots: name, job, hue and core instructions. The * Saves one of the user's bots: name, job, colour, stone and core
* coordinator keeps its name and stays hue-less, so only its instructions * instructions. The coordinator keeps its name and stays hue-less, so only
* change. Returns the saved bot. * 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 }); await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt }); assertPromptLength({ label: "Instructions", text: system_prompt });
@@ -37,8 +52,8 @@ export default async function updateBot({ user_id, bot_id, name, role, hue, syst
}); });
} else { } else {
await BunSQLite.sql({ await BunSQLite.sql({
sql: `UPDATE bots SET name = ?, role = ?, hue = ?, system_prompt = ?, updated_at = ? WHERE id = ?`, sql: `UPDATE bots SET name = ?, role = ?, color = ?, stone = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [name, role || null, hue, system_prompt, Date.now(), bot.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>({ 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 b.image_media_id AS bot_image_media_id, w.name AS workspace_name
FROM cron_jobs c FROM cron_jobs c
JOIN bots b ON b.id = c.bot_id 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 face = botFaceSql({ user_id, alias: "b", prefix: "bot_" });
const row_res = await BunSQLite.sql<TaskDetail>({ 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 b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
FROM tasks t FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id 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 face = botFaceSql({ user_id: user_id || 0, alias: "b", prefix: "bot_" });
const res = await BunSQLite.sql<TaskWithNames>({ 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, 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 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 (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.name AS bot_name,
b.image_media_id AS bot_image_media_id, b.image_media_id AS bot_image_media_id,
b.hue AS bot_hue, b.hue AS bot_hue,
b.color AS bot_color,
b.stone AS bot_stone,
${face.sql}, ${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count, (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 (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
+9 -4
View File
@@ -5,7 +5,7 @@ import { AppData } from "@/src/data/app-data";
import pushBotCreated from "@/src/bots-queue/push-bot-created"; import pushBotCreated from "@/src/bots-queue/push-bot-created";
import createBot from "@/src/functions/backend/db/bots/create-bot"; import createBot from "@/src/functions/backend/db/bots/create-bot";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace"; 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 botRef from "@/src/utils/bot-ref";
import type { HarnessTool } from "../../types"; import type { HarnessTool } from "../../types";
@@ -76,19 +76,24 @@ export default function createBotTool({
); );
} }
const hues = const colors =
( (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({ await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT hue FROM bots`, sql: `SELECT color FROM bots`,
values: [], values: [],
}) })
).payload || []; ).payload || [];
const { color, stone } = pickFreeColor({
taken: colors.map((bot) => bot.color),
});
const bot_id = await createBot({ const bot_id = await createBot({
user_id, user_id,
name: args.name, name: args.name,
role: args.role, role: args.role,
hue: pickFreeHue({ taken: hues.map((bot) => bot.hue) }), color,
stone,
system_prompt: args.instructions, system_prompt: args.instructions,
}); });
+10 -3
View File
@@ -1,9 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data"; 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 grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
import updateBot from "@/src/functions/backend/db/bots/update-bot"; import updateBot from "@/src/functions/backend/db/bots/update-bot";
import { paletteColor, resolveBotStone } from "@/src/utils/bot-color";
import type { HarnessTool } from "../../types"; import type { HarnessTool } from "../../types";
type Params = { type Params = {
@@ -24,7 +24,8 @@ type Args = {
/** /**
* Lets the coordinator change an existing crew member: its name, its * 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 * 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 * Workspace-only instructions have their own tool
* (`set_bot_workspace_instructions`). * (`set_bot_workspace_instructions`).
*/ */
@@ -71,7 +72,13 @@ export default function updateBotTool({ user_id, workers }: Params): HarnessTool
bot_id: worker.id, bot_id: worker.id,
name: args.name ?? worker.name ?? "Bot", name: args.name ?? worker.name ?? "Bot",
role: args.role ?? worker.role ?? "", 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 ?? "", system_prompt: args.instructions ?? worker.system_prompt ?? "",
}); });
+14 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; 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 { import type {
BotCreatedData, BotCreatedData,
BotRemovedData, BotRemovedData,
@@ -78,7 +78,19 @@ export default function useLiveBots({ bots, open_bot_id, workspace_id = null }:
return prev.map((bot) => return prev.map((bot) =>
bot.id == data.id 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, : bot,
); );
}), }),
+5 -4
View File
@@ -2,8 +2,9 @@ import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 type { BotWithLastMessage } from "@/src/types";
import botStoneProps from "@/src/utils/bot-stone-props";
import relativeTime from "@/src/utils/relative-time"; import relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot"; import UnreadDot from "./unread-dot";
@@ -16,7 +17,7 @@ type Props = {
* One bot in the all-bots modal: stone, name, job, last activity. * One bot in the all-bots modal: stone, name, job, last activity.
*/ */
export default function AllBotsRow({ bot, href }: Props) { 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; const at = bot.last_message_at || bot.last_activity_at;
return ( return (
@@ -25,7 +26,7 @@ export default function AllBotsRow({ bot, href }: Props) {
title={bot.name} 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" 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!"> <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-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"> <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) })} {relativeTime({ at: Number(at) })}
</Span> </Span>
) : null} ) : null}
{bot.unread && hue ? <UnreadDot hue={hue} /> : null} {bot.unread && stone_props.color ? <UnreadDot color={stone_props.color} /> : null}
</Link> </Link>
); );
} }
@@ -15,7 +15,7 @@ type Props = (typeof BotHues)[number] & {
* One colour tile: a stone in that hue over its name. Selected is a strong * One colour tile: a stone in that hue over its name. Selected is a strong
* border, so it never depends on the colour alone. * 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 ( return (
<Button <Button
title={title} 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"> <Stack className="w-full min-w-0 gap-1! items-center">
<BotStone hue={value} face={selected ? "done" : "idle"} size={28} /> <BotStone
<Span className={twMerge("text-xs leading-4 text-ink-secondary truncate", selected && "font-bold text-ink")}> 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} {title}
</Span> </Span>
</Stack> </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 Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import BotStone from "@/src/components/general/bot-stone"; 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 = { type Props = {
name: string; name: string;
@@ -11,7 +12,8 @@ type Props = {
/** /**
* Null for the coordinator. * Null for the coordinator.
*/ */
hue: BotHue | null; color: string | null;
stone: BotStoneShape;
image_media_id?: number | string | null; image_media_id?: number | string | null;
picked_src?: string; picked_src?: string;
}; };
@@ -21,23 +23,50 @@ type Props = {
* or stone, name and job, and its colour (a small stone beside the name * or stone, name and job, and its colour (a small stone beside the name
* when a picture hides the big one). * 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 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 ( return (
<Row className="min-w-0 grow gap-3! flex-nowrap items-center"> <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!"> <Stack className="min-w-0 gap-0!">
<Row className="min-w-0 w-full gap-1.5! flex-nowrap items-center"> <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> <Span className="text-sm font-bold leading-5 text-ink truncate">
{has_picture && hue ? <BotStone hue={hue} size={18} label={`${hue_title} bot`} /> : null} {name.trim() || "Its name"}
</Span>
{has_picture && color ? (
<BotStone
color={color}
stone={stone}
size={18}
label={`${color_title} bot`}
/>
) : null}
</Row> </Row>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full"> <Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{role.trim() || "Its job"} {role.trim() || "Its job"}
</Span> </Span>
{hue_title ? ( {color_title ? (
<Span className="text-[11px] leading-4 text-ink-secondary">{hue_title}</Span> <Span className="text-[11px] leading-4 text-ink-secondary">
{color_title}
</Span>
) : null} ) : null}
</Stack> </Stack>
</Row> </Row>
+23 -12
View File
@@ -6,14 +6,15 @@ import CodeEditor from "@/src/components/general/code-editor";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import ImagePickField from "@/src/components/general/image-pick-field"; import ImagePickField from "@/src/components/general/image-pick-field";
import { AppContext } from "@/src/pages/__root"; 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 setProfileImage from "@/src/functions/frontend/set-profile-image";
import postAdminApi from "../(functions)/post-admin-api"; import postAdminApi from "../(functions)/post-admin-api";
import usePickedImage from "../(hooks)/use-picked-image"; 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 AddFormActions from "./add-form-actions";
import BotColourPicker from "./bot-colour-picker";
import BotFormPreview from "./bot-form-preview"; import BotFormPreview from "./bot-form-preview";
import BotHuePicker from "./bot-hue-picker";
type Props = { type Props = {
/** /**
@@ -46,10 +47,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
const [name, setName] = useState(bot?.name || ""); const [name, setName] = useState(bot?.name || "");
const [role, setRole] = useState(bot?.role || ""); 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) || resolveBotColor({ color: bot?.color, hue: bot?.hue }) ||
pickFreeHue({ taken: crew.map((b) => b.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 [system_prompt, setSystemPrompt] = useState(bot?.system_prompt || "");
const [join, setJoin] = useState(!bot && Boolean(workspace?.id)); const [join, setJoin] = useState(!bot && Boolean(workspace?.id));
@@ -79,13 +84,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
bot_id: bot.id, bot_id: bot.id,
name: name || bot.name, name: name || bot.name,
role, role,
hue, color,
stone,
system_prompt, system_prompt,
}, },
}) })
: await postAdminApi<{ bot_id: number }>({ : await postAdminApi<{ bot_id: number }>({
path: "bots/create-bot", path: "bots/create-bot",
body: { name, role, hue, system_prompt, workspace_id }, body: { name, role, color, stone, system_prompt, workspace_id },
}); });
const bot_id = const bot_id =
bot?.id || bot?.id ||
@@ -148,7 +154,8 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
<BotFormPreview <BotFormPreview
name={name} name={name}
role={role} role={role}
hue={is_coordinator ? null : hue} color={is_coordinator ? null : color}
stone={stone}
image_media_id={image.image_media_id} image_media_id={image.image_media_id}
picked_src={image.data_url} picked_src={image.data_url}
/> />
@@ -182,10 +189,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
info="One line, shown under its name." info="One line, shown under its name."
onChange={(e) => setRole(e.target.value)} onChange={(e) => setRole(e.target.value)}
/> />
<BotHuePicker <BotColourPicker
hue={hue} color={color}
stone={stone}
disabled={loading} 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 -4
View File
@@ -5,7 +5,8 @@ import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotWithLastMessage } from "@/src/types"; 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 messagePreview from "@/src/utils/message-preview";
import relativeTime from "@/src/utils/relative-time"; import relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot"; import UnreadDot from "./unread-dot";
@@ -31,7 +32,7 @@ type Props = {
* RailBotCard, which also holds the sessions. * RailBotCard, which also holds the sessions.
*/ */
export default function BotRow({ bot, href, selected, subtitle }: Props) { 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 = const snippet =
bot.status_line || bot.status_line ||
subtitle || subtitle ||
@@ -52,7 +53,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
selected && "hover:bg-transparent hover:dark:bg-transparent", 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!"> <Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full"> <Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{bot.name} {bot.name}
@@ -67,7 +68,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
{relativeTime({ at: Number(at) })} {relativeTime({ at: Number(at) })}
</Span> </Span>
) : null} ) : null}
{bot.unread && hue ? <UnreadDot hue={hue} /> : null} {bot.unread && stone_props.color ? <UnreadDot color={stone_props.color} /> : null}
</Stack> </Stack>
</Link> </Link>
); );
+3 -2
View File
@@ -3,7 +3,8 @@ import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { BotWithLastMessage } from "@/src/types";
type Props = { 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)]"> <Row className="h-14 items-center gap-3 rounded-default px-3 flex-nowrap hover:bg-sunken transition-colors duration-[var(--m-fast)]">
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
/> />
<Stack className="grow min-w-0 gap-0!"> <Stack className="grow min-w-0 gap-0!">
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { BotWithLastMessage } from "@/src/types";
type Props = { type Props = {
@@ -13,7 +14,7 @@ export default function RailSeeAllStone({ bot }: Props) {
return ( return (
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
size={18} size={18}
/> />
@@ -5,7 +5,8 @@ import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import UserAvatar from "@/src/components/general/user-avatar"; import UserAvatar from "@/src/components/general/user-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { UserTypes } from "@/src/dict/user-types-dict"; 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 { import {
SettingsRecordBack, SettingsRecordBack,
SettingsRecordSections, SettingsRecordSections,
@@ -91,7 +92,7 @@ export default function RailSettingsAside({ pathname }: Props) {
avatar = ( avatar = (
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
/> />
); );
+17 -12
View File
@@ -1,19 +1,24 @@
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import type { BotHue } from "@/src/dict/bot-dict"; type Props = {
import { BotHueBgClasses } from "@/src/dict/bot-hue-classes-dict"; /**
* 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 * The unread marker on a rail row. It keeps the bot's colour rather than
* time carry the same information), which is why it may fail 3:1 on some * an accent, and it never replaces the snippet or time.
* hues. Never remove the snippet or time.
*/ */
export default function UnreadDot({ hue }: { hue: BotHue }) { export default function UnreadDot({ color }: Props) {
return ( return (
<Span <Span className="shrink-0" aria-label="Unread" role="img">
className={twMerge("size-1.5 rounded-full mr-1", BotHueBgClasses[hue])} <svg width={6} height={6} viewBox="0 0 6 6" aria-hidden="true">
aria-label="Unread" <circle cx={3} cy={3} r={3} fill={color} />
role="img" </svg>
/> </Span>
); );
} }
@@ -1,7 +1,8 @@
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages"; import type { MessageGroup } from "../(utils)/group-messages";
import BotMessageLine from "./bot-message-line"; 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"> <Row className="w-full gap-3! flex-nowrap items-start">
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
size={28} size={28}
className="-mt-1" className="-mt-1"
@@ -6,7 +6,8 @@ import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { BotWithLastMessage } from "@/src/types";
import BotForm from "@/src/layouts/bots/(partials)/bot-form"; import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
@@ -35,7 +36,7 @@ export default function BotProfileModal({ bot }: Props) {
const avatar = ( const avatar = (
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
size={35} size={35}
/> />
@@ -2,7 +2,8 @@ import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link"; import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { AppContext } from "@/src/pages/__root";
import { CrewBotsContext } from "../(data)/crew-bots-context"; import { CrewBotsContext } from "../(data)/crew-bots-context";
@@ -37,7 +38,7 @@ export default function BotRefChip({ bot_id }: Props) {
> >
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
label={bot.name || ""} label={bot.name || ""}
size={18} size={18}
@@ -2,7 +2,8 @@ import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { import {
BotComputerBusyStatusLine, BotComputerBusyStatusLine,
BotGettingMessageStatusLine, BotGettingMessageStatusLine,
@@ -80,7 +81,7 @@ export default function BotThinkingRow({
<Row role="status" className="w-full gap-3! flex-nowrap items-center"> <Row role="status" className="w-full gap-3! flex-nowrap items-center">
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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"} face={waiting ? "asleep" : "working"}
size={28} size={28}
/> />
@@ -3,7 +3,8 @@ import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-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 { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context"; import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
@@ -30,7 +31,7 @@ export default function ComposerMentionChip({ node, selected }: ReactNodeViewPro
{bot ? ( {bot ? (
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
label={bot.name || ""} label={bot.name || ""}
size={18} size={18}
@@ -2,7 +2,8 @@ import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-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"; import type { ComposerMentionItem } from "../(utils)/search-mention-items";
type Props = { type Props = {
@@ -35,7 +36,7 @@ export default function ComposerMentionOption({ item, active, onPick }: Props) {
{bot ? ( {bot ? (
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
label={bot.name || ""} label={bot.name || ""}
size={22} size={22}
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar"; 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 type { CronJobWithNames } from "@/src/types";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row"; import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action"; 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={ avatar={
<BotAvatar <BotAvatar
image_media_id={cron_job.bot_image_media_id} 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} face={(cron_job.bot_face || "idle") as BotFace}
/> />
} }
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { BotWithLastMessage } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row"; import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action"; import SettingsRowAction from "../../(partials)/settings-row-action";
@@ -23,7 +24,7 @@ export default function BotSettingsRow({ bot, busy, onDelete }: Props) {
avatar={ avatar={
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} 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 Stack from "@moduletrace/twui/layout/Stack";
import { MessageSquare } from "lucide-react"; import { MessageSquare } from "lucide-react";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 { BotWithLastMessage } from "@/src/types";
import SettingsGroup from "../../../(partials)/settings-group"; 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"> <Row className="w-full gap-4! flex-nowrap items-center">
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
size={40} size={40}
/> />
@@ -1,6 +1,7 @@
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotWithFace } from "@/src/types"; 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 SettingsListRow from "../../../(partials)/settings-list-row";
import SettingsRowAction from "../../../(partials)/settings-row-action"; import SettingsRowAction from "../../../(partials)/settings-row-action";
@@ -30,7 +31,7 @@ export default function ComputerBotRow({
avatar={ avatar={
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
/> />
} }
@@ -1,6 +1,7 @@
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-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_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types"; 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, subtitle: bot.role,
href: `/admin/settings/bots/${bot.id}`, href: `/admin/settings/bots/${bot.id}`,
avatar: ( 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} onUpdated={onUpdated}
@@ -5,7 +5,8 @@ import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import { MessageSquare, NotebookPen, X } from "lucide-react"; import { MessageSquare, NotebookPen, X } from "lucide-react";
import BotAvatar from "@/src/components/general/bot-avatar"; 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"; import type { WorkspaceBot } from "@/src/types";
type Props = { 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"> <Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
/> />
<Link <Link
@@ -1,6 +1,7 @@
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar"; 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 SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import type { WorkspaceActivityItem } from "@/src/types"; import type { WorkspaceActivityItem } from "@/src/types";
import messagePreview from "@/src/utils/message-preview"; import messagePreview from "@/src/utils/message-preview";
@@ -26,7 +27,7 @@ export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
avatar={ avatar={
<BotAvatar <BotAvatar
image_media_id={item.bot_image_media_id} 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} face={(item.bot_face || "idle") as BotFace}
/> />
} }
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar"; 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 SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import type { WorkspaceBot } from "@/src/types"; import type { WorkspaceBot } from "@/src/types";
@@ -22,7 +23,7 @@ export default function WorkspaceDashboardBotRow({ workspace_id, bot }: Props) {
avatar={ avatar={
<BotAvatar <BotAvatar
image_media_id={bot.image_media_id} 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} face={(bot.face || "idle") as BotFace}
/> />
} }
+2 -2
View File
@@ -33,13 +33,13 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<{ bot_id: number }
throw new AppError(`This user can only read records.`); 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) { if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
throw new AppError(`Workspace not found.`); 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) { if (workspace_id) {
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id }); await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
+4 -4
View File
@@ -13,8 +13,8 @@ import type {
} from "@moduletrace/bunext/types"; } from "@moduletrace/bunext/types";
/** /**
* Saves one of the session user's bots (name, job, hue, instructions). * Saves one of the session user's bots (name, job, colour, stone,
* Returns the bot. * instructions). Returns the bot.
*/ */
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_BOTS>> = async ({ req, body }) => { export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_BOTS>> = async ({ req, body }) => {
try { try {
@@ -32,12 +32,12 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
throw new AppError(`This user can only read records.`); 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, schema: UpdateBotRequestSchema,
data: body, 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({ await logEvent({
title: "Bot changed", title: "Bot changed",
+14 -3
View File
@@ -1,10 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { import {
BotHues,
BotMessageReactions, BotMessageReactions,
type BotHue, BotStoneShapes,
type BotMessageReactionEmoji, type BotMessageReactionEmoji,
type BotStoneShape,
} from "@/src/dict/bot-dict"; } from "@/src/dict/bot-dict";
/** /**
@@ -115,7 +115,18 @@ export type ListBotSessionsRequest = z.infer<typeof ListBotSessionsRequestSchema
export const CreateBotRequestSchema = z.object({ export const CreateBotRequestSchema = z.object({
name: z.string().trim().min(1, "Give the bot a name").max(40, "Keep the name under 40 characters"), 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(""), 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 system_prompt: z
.string() .string()
.trim() .trim()
+61
View File
@@ -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);
+7 -1
View File
@@ -13,6 +13,7 @@ import type {
BotMessageAuthor, BotMessageAuthor,
BotTaskStepState, BotTaskStepState,
} from "@/src/dict/bot-dict"; } from "@/src/dict/bot-dict";
import { paletteColor } from "@/src/utils/bot-color";
import setBotFace from "@/src/functions/backend/db/bots/set-bot-face"; import setBotFace from "@/src/functions/backend/db/bots/set-bot-face";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot"; import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session"; import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
@@ -49,6 +50,10 @@ type SeedTask = {
type SeedBot = { type SeedBot = {
name: string; 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; hue: BotHue | null;
role: string | null; role: string | null;
face: BotFace; face: BotFace;
@@ -221,7 +226,8 @@ async function insertBot({
user_id, user_id,
name: bot.name, name: bot.name,
kind: bot.hue ? "worker" : "coordinator", kind: bot.hue ? "worker" : "coordinator",
hue: bot.hue || undefined, color: paletteColor({ hue: bot.hue }) || undefined,
stone: bot.hue || undefined,
role: bot.role || undefined, role: bot.role || undefined,
}, },
], ],
+12
View File
@@ -83,6 +83,8 @@ export type CronJobWithNames = BUN_SQLITE_COBBLE_CRON_JOBS & {
bot_name?: string | null; bot_name?: string | null;
bot_kind?: string | null; bot_kind?: string | null;
bot_hue?: string | null; bot_hue?: string | null;
bot_color?: string | null;
bot_stone?: string | null;
bot_face?: string | null; bot_face?: string | null;
bot_image_media_id?: number | null; bot_image_media_id?: number | null;
workspace_name?: string | null; workspace_name?: string | null;
@@ -96,6 +98,8 @@ export type TaskWithNames = BUN_SQLITE_COBBLE_TASKS & {
bot_name?: string | null; bot_name?: string | null;
bot_kind?: string | null; bot_kind?: string | null;
bot_hue?: string | null; bot_hue?: string | null;
bot_color?: string | null;
bot_stone?: string | null;
bot_face?: string | null; bot_face?: string | null;
bot_image_media_id?: number | null; bot_image_media_id?: number | null;
workspace_name?: string | null; workspace_name?: string | null;
@@ -160,6 +164,8 @@ export type WorkspaceActivityItem = BUN_SQLITE_COBBLE_BOT_SESSIONS & {
bot_name?: string | null; bot_name?: string | null;
bot_image_media_id?: number | null; bot_image_media_id?: number | null;
bot_hue?: string | null; bot_hue?: string | null;
bot_color?: string | null;
bot_stone?: string | null;
bot_face?: 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. * change). A workspace's crew drops a bot that's no longer in it.
*/ */
workspace_ids?: number[]; 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;
}; };
/** /**
+61
View File
@@ -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();
});
});
+103
View File
@@ -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;
}
+34
View File
@@ -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 }),
};
}
+28
View File
@@ -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");
});
});
+33
View File
@@ -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 };
}
-14
View File
@@ -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");
});
});
-22
View File
@@ -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;
}