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