Compare commits

...
14 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 243c19e0c5 Release v0.9.0
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:26:20 +01:00
tbenandClaude Opus 5.5 1e7834ba7f Add a guided setup after signup, and a no-model notice in conversations
The first super admin now lands on Settings > Setup after signing up: a
skippable walk through the app name, connecting a model provider and
picking models, ending with where to go next. It stays under Settings to
come back to.

While a bot has no text model, its conversation shows a notice in the
thread (centred when empty): super admins get Connect a provider and
Guided setup, everyone else is told the super admin sets it up. The
models modal keeps it current.

The Models page's data and save logic move into useModelsOverview, shared
with the setup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:26:20 +01:00
tbenandClaude Opus 5.5 c9a24c05af Release v0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:10:23 +01:00
tbenandClaude Opus 5.5 d7dfe0e78e Let bots ask multiple-choice questions before acting
ask_user puts up to six questions on one card (radios for one pick,
checkboxes for several, an optional typed Other) as a [question:ID] chip.
The user's answers are checked against the questions, stored in
bot_questions and sent to the bot as their chat message; Skip says so.
Offered in chat replies and user-started tasks, never in routines, hook
calls or chatroom sessions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:10:23 +01:00
tbenandClaude Opus 5.5 07f87ce4ea Release v0.7.6
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 f84f0fd0d6 Name sessions opened by handed-off work, and hide empty ones from activity
A session the coordinator's delegation, a routine or a hook opened has
no user message and no reply, so it was never named and showed as
"Untitled conversation". Tasks now name their session too, and a session
with no user message is named from the bot's own messages. Until a
session is named, Latest activity shows what it opened with, and
sessions with no messages are left out of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 b4b27dea10 Truncate long list row titles instead of overflowing on mobile
Stack lines its children up at the start, so a linked row title grew to
its full one-line width and pushed the card past the screen edge. The
link now fills the row and the title is a block, so it truncates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 596a417229 Release v0.7.5
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:27:06 +01:00
tbenandClaude Opus 5.5 623112fd19 Give every task a bot: drop shared workspace tasks
A workspace task with no bot was seen by every bot working there. Now a
task can't be saved without a bot, a worker reaches only its own (the
workspace admin and the coordinator still reach all), the workspace page
picks who has a new task, and a bot leaving a workspace or deleted passes
its tasks there to the workspace's admin bot, else the coordinator (also
at boot for rows without a bot).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:26:44 +01:00
tbenandClaude Opus 5.5 3e78d51b56 Keep bot messages inside the column on narrow screens
Lists no longer add twui's 25px list margin on top of full width,
markdown blocks and list items are capped at the column, long words
and inline code wrap anywhere, and ref chips with long labels truncate
instead of pushing the line past the edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:23:09 +01:00
tbenandClaude Opus 5.5 4e20106c59 Release v0.7.4
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:49:42 +01:00
tbenandClaude Opus 5.5 c547e556ec Open a task in its own modal from its title, with status moves, and edit its brief in the code editor
The modal shows who has the task and where, who added it and when it
moved, its files and notes (each note naming its bot). The task form's
fields all show their labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:49:42 +01:00
tbenandClaude Opus 5.5 74da091dd4 Release v0.7.3
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:09 +01:00
tbenandClaude Opus 5.5 a33593460b Delete a task from its list row, keep the status chip unfilled in light mode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:09 +01:00
120 changed files with 2906 additions and 427 deletions
+7 -5
View File
@@ -54,7 +54,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Auth
- **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`.
- **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only, which then opens the skippable guided setup `/admin/settings/setup`: app name, provider, models, where to go next; it stays under Settings); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`.
- **Session:** AES-256-GCM cookie (key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus CSRF cookie, `Secure` only over HTTPS (`isSecureRequest`, honours `X-Forwarded-Proto`). `userAuth` (`src/functions/backend/auth/user-auth.ts`) checks expiry (60 days), CSRF (constant-time) and re-reads `user_types` every request.
- **Login:** email/username + password (`api/auth/password-login.ts`, rate limited). No emailed codes or resets. Google login only for existing accounts, when `GOOGLE_CLIENT_ID` is set. Sessions issued by `login-user.ts`.
- **Passwords:** bcrypt in `user_passwords` (never in `users`; blocked from CRUD via `src/dict/private-tables.ts`).
@@ -88,14 +88,16 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Bots, sessions, chat
- **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`.
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening (`grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any.
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening after a reply or a task (a session with no user message, work handed to the bot, is named from the bot's own messages; `grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any.
- **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty.
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
- **No model:** while the bot has no text model there (`bot_text_model` null, kept live by the models modal), the thread shows `NoModelNotice` (centred when empty): super admins get Connect a provider / Guided setup, others are told the super admin sets it up.
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes.
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working.
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[question:ID]` (a bot's questions), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working.
- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch.
- **Asking before acting:** `ask_user` (`QuestionsNote`) puts up to `AppData.BotQuestionMaxQuestions` multiple-choice questions (`single` = radios, `multiple` = checkboxes, optional typed "Other") on one card, once per run -> `bot_questions` -> `[question:ID]` chip opening `BotQuestionCard`; the message needs the user. Answers are checked against the questions (`checkBotQuestionAnswers`) and stored, then sent as the user's chat message (`botQuestionAnswersText`); Skip says so. Replies and user-started tasks only: never routines, hook calls or chatroom sessions (no one sees those).
- **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`.
- **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, no text kept: a reply reads a document's text from the file), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet.
- **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates.
@@ -153,7 +155,7 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
### Tasks (the to-do tracker)
`tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
`tasks` (**every task belongs to a bot**, the coordinator included, never shared: a bot's own list is `bot_id` alone, a workspace's task has `workspace_id` plus the `bot_id` that has it there, enforced by `taskScopeValues`; a worker reaches only its own, a workspace admin all of that workspace's, the coordinator all, `botTaskScopesSql`; a bot removed from a workspace or deleted passes its tasks there to the workspace's admin bot, else the coordinator (`passOnBotTasks`, also run at boot for rows without a bot)), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`); a card's title opens `TaskDetailModal` with everything about the task and its status moves (a modal over the list, never swapped in place). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
### Envs
@@ -225,7 +227,7 @@ Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspa
`AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/chatrooms`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout.
- **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside.
- **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `profile`, `notifications`, `change-password`, `secrets`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills`, `knowledgebases` (crew managers), `users` (`canListUsers`).
- **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `setup` (super admin; shares the Models page's server props and `useModelsOverview`), `profile`, `notifications`, `change-password`, `secrets`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills`, `knowledgebases` (crew managers), `users` (`canListUsers`).
- Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`).
- **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process.
- **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`.
+58
View File
@@ -35,6 +35,7 @@ import {
} from "@/src/dict/bot-file-dict";
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
import { EnvParadigms, SecretRequestStatuses } from "@/src/dict/env-dict";
import { BotQuestionStatuses } from "@/src/dict/bot-question-dict";
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
import {
CustomToolKinds,
@@ -1652,6 +1653,63 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
],
},
{
tableName: "bot_questions",
tableDescription: `Questions a bot asked a user in their conversation before acting (\`ask_user\`), each answered by picking one option (radios) or several (checkboxes), optionally with a typed "Other". The user answers them in the card, and the answers go to the bot as their chat message`,
fields: [
{
fieldName: "title",
fieldDescription: `A short heading for the card, in the bot's words`,
dataType: "TEXT",
},
{
fieldName: "questions",
fieldDescription: `JSON array of { question, kind (single | multiple), options, allow_other }`,
dataType: "TEXT",
},
{
fieldName: "answers",
fieldDescription: `JSON array, one per question, of { choices (option indexes), other }; null until answered`,
dataType: "TEXT",
},
{
fieldName: "status",
dataType: "TEXT",
options: [...BotQuestionStatuses],
defaultValue: "pending",
},
{
fieldName: "decided_at",
dataType: "INTEGER",
},
{
fieldName: "user_id",
fieldDescription: `The user they were asked of, who alone answers them. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "bot_id",
fieldDescription: `The bot that asked. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "session_id",
fieldDescription: `The conversation they were asked in. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "workspace_id",
fieldDescription: `The session's workspace; null for the bot's own chat. Not a foreign key`,
dataType: "INTEGER",
},
],
indexes: [
{
indexName: "idx_bot_questions_session_id",
indexTableFields: ["session_id"],
},
],
},
{
tableName: "bot_graphics",
tableDescription: `A visual a bot made for a user (a chart, diagram, timeline or report) as one self-contained HTML document. Served by the app at \`/graphics/<id>\` in a sandbox with no network and an origin of its own, so it never runs with the viewer's session. Per user like the conversation it was made in; an edit replaces the HTML and bumps \`version\``,
+47 -1
View File
@@ -22,6 +22,7 @@ export const BunSQLiteTables = [
"bot_hook_events",
"hook_proposals",
"secret_requests",
"bot_questions",
"bot_graphics",
"workspaces",
"workspace_bots",
@@ -1046,6 +1047,51 @@ export type BUN_SQLITE_COBBLE_SECRET_REQUESTS = {
workspace_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_QUESTIONS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
/**
* A short heading for the card, in the bot's words
*/
title?: string;
/**
* JSON array of { question, kind (single | multiple), options, allow_other }
*/
questions?: string;
/**
* JSON array, one per question, of { choices (option indexes), other }; null until answered
*/
answers?: string;
status?: "pending" | "answered" | "skipped" | "";
decided_at?: number | "";
/**
* The user they were asked of, who alone answers them. Not a foreign key
*/
user_id?: number | "";
/**
* The bot that asked. Not a foreign key
*/
bot_id?: number | "";
/**
* The conversation they were asked in. Not a foreign key
*/
session_id?: number | "";
/**
* The session's workspace; null for the bot's own chat. Not a foreign key
*/
workspace_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_GRAPHICS = {
/**
* The unique identifier of the record.
@@ -2470,4 +2516,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
value?: string;
}
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_CHATROOMS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_SECRET_REQUESTS & BUN_SQLITE_COBBLE_BOT_GRAPHICS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_VECTORS & BUN_SQLITE_COBBLE_BOT_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_WORKSPACE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_CHATROOMS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_SECRET_REQUESTS & BUN_SQLITE_COBBLE_BOT_QUESTIONS & BUN_SQLITE_COBBLE_BOT_GRAPHICS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_VECTORS & BUN_SQLITE_COBBLE_BOT_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_WORKSPACE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cobble",
"version": "0.7.2",
"version": "0.9.0",
"module": "index.ts",
"type": "module",
"private": true,
+4 -7
View File
@@ -7,6 +7,7 @@ import generateText from "@/src/harness/text/generate-text";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import parseSessionName from "./parse-session-name";
import sessionNameTranscript from "./session-name-transcript";
import sessionReadyToName from "./session-ready-to-name";
type Params = {
session_id: number | string;
@@ -16,7 +17,8 @@ type Params = {
};
/**
* Names a session once its first exchange is in: the bot writes the title
* Names a session once its first exchange is in (or, with no user message,
* once the bot has written in it: work handed to it): the bot writes the title
* the rail and sessions list show and a short summary of it, from the
* session's first messages (however late the first reply came). The summary
* says how many messages it covers (`summary_message_count`), so
@@ -51,12 +53,7 @@ export default async function nameBotSession({
limit: AppData.BotSessionNameMessages,
});
if (
!messages.some((message) => message.author == "user") ||
!messages.some((message) => message.author == "bot" && !message.carried_from_session_id)
) {
return false;
}
if (!sessionReadyToName({ messages })) return false;
const transcript = sessionNameTranscript({ messages });
+3 -2
View File
@@ -235,9 +235,10 @@ export default async function processBotJob({
* Once the first exchange is sent, the bot names the session and
* previews it. After the reply is pushed, so it never waits on it;
* a model that can't answer now leaves the session for the next
* reply to name.
* job to name. Tasks name too: a session the coordinator's
* delegation or a routine opened has no reply to name it.
*/
if (job.kind == "reply" && !in_room && completion.message?.session_id) {
if (!in_room && completion.message?.session_id) {
/**
* The message's session, not the job's as it was claimed: a
* reply that moved the conversation on answered in the new one.
+10
View File
@@ -3,6 +3,7 @@ import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings
import { AppData } from "@/src/data/app-data";
import {
ActionCheckNote,
QuestionsNote,
AskCrewNote,
SecretsNote,
GraphicsNote,
@@ -53,6 +54,7 @@ import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-an
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
import askUserTool from "@/src/harness/tools/bot/questions/ask-user-tool";
import type { BotMessageWithAttachments } from "@/src/types";
import type { HarnessMessage } from "@/src/harness/types";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -324,6 +326,7 @@ export default async function runBotReply({
TasksNote,
TaskReminderNote,
ActionCheckNote,
QuestionsNote,
SecretsNote,
composeEnvsNote({ ...envs, has_computer: computers.length > 0 }),
GraphicsNote,
@@ -366,6 +369,13 @@ export default async function runBotReply({
onRequest: ({ ref }) => proposals.push(ref),
onShow: ({ ref }) => shown.push(ref),
}),
askUserTool({
user_id,
bot_id: bot.id,
session_id,
workspace_id,
onAsk: ({ ref }) => proposals.push(ref),
}),
...(has_past_sessions
? [searchPastSessionsTool(past_scope), readPastSessionTool(past_scope)]
: []),
+21
View File
@@ -5,6 +5,7 @@ import workspaceAdminsNote from "./workspace-admins-note";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import {
ActionCheckNote,
QuestionsNote,
SecretsNote,
GraphicsNote,
botFilesNote,
@@ -27,6 +28,7 @@ import openBotSkills, { type OpenBotSkills } from "@/src/harness/tools/skills/op
import openBotKnowledgebases from "@/src/harness/tools/knowledgebases/open-bot-knowledgebases";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
import askUserTool from "@/src/harness/tools/bot/questions/ask-user-tool";
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
@@ -134,6 +136,12 @@ export default async function runBotTask({
* like a routine, and treats the call's payload as data.
*/
const hooked = Boolean(params.hook_id);
/**
* Only a task the user follows in their conversation asks them
* questions: routines and hook calls act on their own, and a
* chatroom's session is hidden from them.
*/
const can_ask = Boolean(session_id) && !scheduled && !hooked && !params.chatroom_id;
let failure = null as string | null;
/**
@@ -207,6 +215,18 @@ export default async function runBotTask({
onRequest: hooked ? undefined : ({ ref }) => take_overs.push(ref),
onShow: ({ ref }) => shown.push(ref),
}),
...(can_ask
? [
askUserTool({
user_id,
bot_id: bot.id,
session_id,
workspace_id,
in_task: true,
onAsk: ({ ref }) => take_overs.push(ref),
}),
]
: []),
...(scheduled || hooked ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []),
...(computer?.tools || []),
...(job.computer_id
@@ -270,6 +290,7 @@ export default async function runBotTask({
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
TasksNote,
ActionCheckNote,
can_ask ? QuestionsNote : "",
SecretsNote,
GraphicsNote,
workspace_id ? WorkspaceLearningNote : "",
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import sessionReadyToName from "./session-ready-to-name";
describe("sessionReadyToName", () => {
test("waits for the bot to answer the user", () => {
expect(sessionReadyToName({ messages: [{ id: 1, author: "user", text: "Hi" }] })).toBe(false);
});
test("names a first exchange", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "user", text: "Hi" },
{ id: 2, author: "bot", text: "Hello" },
],
}),
).toBe(true);
});
test("names work handed to the bot, with no user message", () => {
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "Coordinator asked me to: **SEO lookup**" }],
}),
).toBe(true);
});
test("a carried summary alone is not the bot's answer", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 },
{ id: 2, author: "user", text: "Continue here" },
],
}),
).toBe(false);
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 }],
}),
).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { SessionOpeningMessage } from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
type Params = {
messages: SessionOpeningMessage[];
};
/**
* Whether a session's opening says enough to name it: the bot has said
* something of its own (a carried summary doesn't count), and, when the
* user wrote, after the user's first message. A session with no user
* message at all is work handed to the bot (the coordinator's delegations,
* routines, hooks), named from the bot's own messages.
*/
export default function sessionReadyToName({ messages }: Params) {
const has_bot_message = messages.some(
(message) => message.author == "bot" && !message.carried_from_session_id,
);
if (!has_bot_message) return false;
const first_user = messages.findIndex((message) => message.author == "user");
if (first_user < 0) return true;
return messages
.slice(first_user + 1)
.some((message) => message.author == "bot" && !message.carried_from_session_id);
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { TaskDetail, TaskWithNames } from "@/src/types";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import taskPlaceLabel from "@/src/utils/task-place-label";
import TaskDetailPanel from "./task-detail-panel";
type Props = {
/**
* The list row clicked; open while set. Its notes and files load when
* it opens, so the list stays small.
*/
task?: TaskWithNames | null;
/**
* A crew manager can change it, file onto it and delete it.
*/
can_manage?: boolean;
onClose: () => void;
onChanged: (params: { task: TaskDetail; msg?: string }) => void;
onDeleted: (params: { task_id: number }) => void;
};
/**
* A task opened from a list in a modal of its own: everything about it, and
* for a crew manager its edit, hand-over, files and delete.
*/
export default function TaskDetailModal({ task, can_manage, onClose, onChanged, onDeleted }: Props) {
const [detail, set_detail] = useState<TaskDetail | null>(null);
const [error, set_error] = useState<string>();
const task_id = task?.id ? Number(task.id) : 0;
useEffect(() => {
set_detail(null);
set_error(undefined);
if (!task_id) return;
let live = true;
fetchApi<{}, APIResponseObject<TaskDetail>>(`/api/admin/tasks/get-task?task_id=${task_id}`, {
method: "GET",
})
.catch(() => null)
.then((res) => {
if (!live) return;
if (!res?.success || !res.singleRes) {
set_error(res?.msg || "Couldn't open the task.");
return;
}
set_detail(res.singleRes);
});
return () => {
live = false;
};
}, [task_id]);
const shown = detail || task;
return (
<AddModalShell
open={Boolean(task_id)}
setOpen={(open) => !open && onClose()}
title={shown?.title || (task_id ? `Task ${task_id}` : "Task")}
description={shown ? taskPlaceLabel({ task: shown }) : undefined}
>
<Stack className="w-full mt-4 gap-0! items-stretch">
{detail ? (
<TaskDetailPanel
key={detail.id}
task={detail}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_detail(saved);
onChanged({ task: saved, msg });
}}
onDeleted={() => onDeleted({ task_id })}
/>
) : (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
{error || "Loading the task..."}
</Span>
)}
</Stack>
</AddModalShell>
);
}
+91 -21
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useContext, useRef, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
@@ -10,6 +10,14 @@ import TaskFileRow from "@/src/components/general/task-file-row";
import HandOverForm from "@/src/components/general/hand-over-form";
import TaskForm from "@/src/components/general/task-form";
import TaskStatusChip from "@/src/components/general/task-status-chip";
import TaskStatusMoves from "@/src/components/general/task-status-moves";
import BotAvatar from "@/src/components/general/bot-avatar";
import TaskFactRow from "@/src/components/general/task-fact-row";
import TaskNoteRow from "@/src/components/general/task-note-row";
import type { BotFace } from "@/src/dict/bot-dict";
import { AppContext } from "@/src/pages/__root";
import botStoneProps from "@/src/utils/bot-stone-props";
import relativeTime from "@/src/utils/relative-time";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
@@ -24,12 +32,14 @@ type Props = {
};
/**
* One task opened: what it is, its notes oldest first, its files (each
* opening its bytes), a box to add a note, and edit or delete. A crew
* One task opened: its status (a crew manager moves it along), who has it and where, who added it and
* when it last moved, what it is, its files (each opening its bytes), its
* notes oldest first, a box to add a note, and edit or delete. A crew
* manager can file a new deliverable straight onto it, or hand an open
* one to another bot, which starts on it.
*/
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
const { pageProps } = useContext(AppContext);
const [editing, setEditing] = useState(false);
const [handing_over, setHandingOver] = useState(false);
const [note, setNote] = useState("");
@@ -135,13 +145,21 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
<Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<TaskStatusChip status={task.status} />
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-secondary truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: task.bot_name || `Bot ${task.bot_id}`}
</Span>
{can_manage ? (
<Row className="shrink-0 gap-1! flex-nowrap">
<TaskStatusMoves
status={task.status}
disabled={busy}
onMove={(next) =>
run({
path: "tasks/save-task",
body: { task_id: Number(task.id), status: next },
done: "Status changed.",
})
}
/>
) : null}
{can_manage ? (
<Row className="ml-auto shrink-0 gap-1! flex-nowrap">
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
<Button
title="Hand the task to another bot"
@@ -200,6 +218,59 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null}
</Row>
<Stack className="w-full gap-1.5! items-stretch">
<TaskFactRow label="Who has it">
{task.bot_id ? (
<>
<BotAvatar
image_media_id={task.bot_image_media_id}
{...botStoneProps({ color: task.bot_color, stone: task.bot_stone, hue: task.bot_hue })}
face={(task.bot_face || "idle") as BotFace}
size={18}
/>
<Span className="min-w-0 truncate">{task.bot_name || `Bot ${task.bot_id}`}</Span>
</>
) : (
<Span className="text-ink-secondary">No bot</Span>
)}
</TaskFactRow>
<TaskFactRow label="Where">
<Span className="min-w-0 truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: `${task.bot_name || "The bot"}'s own list`}
</Span>
</TaskFactRow>
<TaskFactRow label="Added">
<Span className="min-w-0 truncate">
{[
task.created_by_bot_id
? `By ${task.created_by_bot_name || `bot ${task.created_by_bot_id}`}`
: task.created_by_user_id
? task.created_by_user_id == pageProps?.user?.id
? "By you"
: `By ${task.created_by_user_name || "a user"}`
: task.origin == "bot"
? "By a bot"
: "By hand",
task.created_at ? relativeTime({ at: Number(task.created_at) }) : null,
]
.filter(Boolean)
.join(" · ")}
</Span>
</TaskFactRow>
{task.updated_at ? (
<TaskFactRow label="Last moved">
<Span>{relativeTime({ at: Number(task.updated_at) })}</Span>
</TaskFactRow>
) : null}
{task.completed_at ? (
<TaskFactRow label="Finished">
<Span>{relativeTime({ at: Number(task.completed_at) })}</Span>
</TaskFactRow>
) : null}
</Stack>
{task.status == "blocked" && task.blocked_reason ? (
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
) : null}
@@ -209,7 +280,9 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null}
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Files</Span>
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
{task.files.length ? `Files (${task.files.length})` : "Files"}
</Span>
{deliverables.length || references.length ? (
<Stack className="w-full gap-0! items-stretch">
{[...deliverables, ...references].map((file) => (
@@ -253,18 +326,15 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
</Stack>
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Notes</Span>
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
{task.notes.length ? `Notes (${task.notes.length})` : "Notes"}
</Span>
{task.notes.length ? (
task.notes.map((entry) => (
<Row key={entry.id} className="w-full gap-2! flex-nowrap items-start">
<Span className="shrink-0 text-[11px] leading-[18px] text-ink-secondary">
{entry.author == "user" ? "You" : "Bot"}
</Span>
<Span className="grow min-w-0 whitespace-pre-wrap text-[13px] leading-[18px] text-ink">
{entry.text}
</Span>
</Row>
))
<Stack className="w-full gap-0! items-stretch">
{task.notes.map((entry) => (
<TaskNoteRow key={entry.id} note={entry} />
))}
</Stack>
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
)}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
label: string;
children: ReactNode;
};
/**
* One fact about an opened task: a label, then its value.
*/
export default function TaskFactRow({ label, children }: Props) {
return (
<Row className="w-full gap-3! flex-nowrap items-center">
<Span className="w-24 shrink-0 text-[12px] leading-[18px] text-ink-secondary">{label}</Span>
<Row className="grow min-w-0 gap-2! flex-nowrap items-center text-[13px] leading-[18px] text-ink">
{children}
</Row>
</Row>
);
}
+74 -27
View File
@@ -6,6 +6,7 @@ import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import Textarea from "@moduletrace/twui/form/Textarea";
import Stack from "@moduletrace/twui/layout/Stack";
import CodeEditor from "@/src/components/general/code-editor";
import { AppData } from "@/src/data/app-data";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
@@ -17,6 +18,11 @@ type Props = {
*/
bot_id?: number | null;
workspace_id?: number | null;
/**
* Adding to a workspace without a `bot_id`: the bots there to pick who
* has it, since every task belongs to a bot.
*/
bots?: { id?: number | string | null; name?: string | null }[];
/**
* Edits this task; adds one when not given.
*/
@@ -26,15 +32,30 @@ type Props = {
};
/**
* The one task form: a title, the brief, the status and, while it's
* The one task form: a title, the brief (in the code editor), the status and, while it's
* blocked, what the user has to do. The workspace's tasks group and the
* conversation's tasks modal both add and edit with it.
*/
export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel }: Props) {
export default function TaskForm({
bot_id,
workspace_id,
bots = [],
task,
onSaved,
onCancel,
}: Props) {
const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState<TaskStatus>((task?.status as TaskStatus) || "todo");
const [blocked_reason, setBlockedReason] = useState(task?.blocked_reason || "");
const [status, setStatus] = useState<TaskStatus>(
(task?.status as TaskStatus) || "todo",
);
const [blocked_reason, setBlockedReason] = useState(
task?.blocked_reason || "",
);
const pick_bot = !task && !bot_id && Boolean(workspace_id);
const [picked_bot_id, setPickedBotId] = useState<number | undefined>(
bots[0]?.id ? Number(bots[0].id) : undefined,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
@@ -46,24 +67,30 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
return;
}
if (pick_bot && !picked_bot_id) {
setError("Pick the bot that has it.");
return;
}
setLoading(true);
setError(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
"/api/admin/tasks/save-task",
{
method: "POST",
body: {
task_id: task?.id ? Number(task.id) : undefined,
bot_id: task ? undefined : bot_id || undefined,
workspace_id: task ? undefined : workspace_id || undefined,
title,
description,
status,
blocked_reason: status == "blocked" ? blocked_reason || null : null,
},
const res = await fetchApi<
Record<string, unknown>,
APIResponseObject<TaskDetail>
>("/api/admin/tasks/save-task", {
method: "POST",
body: {
task_id: task?.id ? Number(task.id) : undefined,
bot_id: task ? undefined : bot_id || picked_bot_id || undefined,
workspace_id: task ? undefined : workspace_id || undefined,
title,
description,
status,
blocked_reason:
status == "blocked" ? blocked_reason || null : null,
},
);
});
setLoading(false);
@@ -77,25 +104,41 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
return (
<Form onSubmit={submit} className="gap-3!">
<Stack className="gap-3! items-stretch">
<Stack className="gap-6! items-stretch">
<Input
title="Title"
label="Title"
showLabel
value={title}
changeHandler={setTitle}
placeholder="What needs doing?"
maxLength={AppData.TaskTitleMaxLength}
disabled={loading}
/>
<Textarea
label="Brief"
value={description}
changeHandler={setDescription}
{pick_bot ? (
<Select
title="Who has it"
label="Who has it"
showLabel
options={bots.map((bot) => ({
title: bot.name || `Bot ${bot.id}`,
value: String(bot.id),
default: Number(bot.id) == picked_bot_id,
}))}
changeHandler={(value) => setPickedBotId(Number(value) || undefined)}
/>
) : null}
<CodeEditor
mode="markdown"
className="h-[500px]"
title="Brief"
placeholder="What to do, where, and how you'll know it's done."
maxLength={AppData.TaskDescriptionMaxLength}
rows={5}
disabled={loading}
content={description}
info={`Up to ${AppData.TaskDescriptionMaxLength.toLocaleString()} characters.`}
onChange={setDescription}
/>
<Select
title="Status"
label="Status"
showLabel
options={TaskStatuses.map((it) => ({
@@ -103,11 +146,15 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
value: it.value,
default: it.value == status,
}))}
changeHandler={(value) => setStatus(String(value) as TaskStatus)}
changeHandler={(value) =>
setStatus(String(value) as TaskStatus)
}
/>
{status == "blocked" ? (
<Textarea
title="Waiting on the user for"
label="Waiting on the user for"
showLabel
value={blocked_reason}
changeHandler={setBlockedReason}
placeholder="What the user has to do before this can go on."
+26 -28
View File
@@ -1,4 +1,5 @@
import { useContext } from "react";
import { twMerge } from "tailwind-merge";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
@@ -8,35 +9,41 @@ import { AppContext } from "@/src/pages/__root";
import BotAvatar from "@/src/components/general/bot-avatar";
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";
import taskPlaceLabel from "@/src/utils/task-place-label";
import { Trash2 } from "lucide-react";
import RowIconButton from "./row-icon-button";
import TaskStatusChip from "./task-status-chip";
import TaskStatusMoves from "./task-status-moves";
type Props = {
task: TaskWithNames;
/**
* Opens the task to read its notes and files, and to edit it.
* Opens the task in its modal: everything about it, and for a crew
* manager its edit, hand-over, files and delete. The title opens it.
*/
onOpen?: () => void;
/**
* Moves it to one status; only crew managers get the controls.
*/
onStatusChange?: (status: string) => void;
/**
* Deletes it after asking; only crew managers get the button.
*/
onDelete?: () => void;
busy?: boolean;
};
/**
* One task: whose it is (a bot's stone), its title and status, where it
* is and who has it, what it's blocked on, its latest note and how long ago it
* moved.
* moved. Its title opens it in a modal.
*/
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, busy }: Props) {
const { pageProps } = useContext(AppContext);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const done = task.status == "done" || task.status == "cancelled";
const moves = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
return (
<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">
@@ -49,18 +56,27 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Stack className="grow min-w-0 gap-0.5! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Button
title={task.title || "Open the task"}
title={`Open ${task.title || `task ${task.id}`}`}
type="button"
variant="ghost"
color="text"
onClick={onOpen}
className={`grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline ${
done ? "text-ink-secondary! line-through" : "text-ink!"
}`}
className={twMerge(
"grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline",
done ? "text-ink-secondary! line-through" : "text-ink!",
)}
>
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
</Button>
<TaskStatusChip status={task.status} />
{can_manage && onDelete ? (
<RowIconButton
icon={Trash2}
title={`Delete ${task.title || `task ${task.id}`}`}
disabled={busy}
onClick={onDelete}
/>
) : null}
</Row>
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
@@ -81,25 +97,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
) : null}
{can_manage && onStatusChange ? (
<Row className="gap-1! flex-nowrap items-center pt-0.5">
{moves
.filter((it) => it.value != task.status)
.map((it) => (
<Button
key={it.value}
title={`Mark it ${it.title.toLowerCase()}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => onStatusChange(it.value)}
className="px-1.5! text-[12px]"
>
{it.title}
</Button>
))}
</Row>
<TaskStatusMoves status={task.status} disabled={busy} onMove={onStatusChange} className="pt-0.5" />
) : null}
</Stack>
</Row>
+31
View File
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { TaskNote } from "@/src/types";
import relativeTime from "@/src/utils/relative-time";
type Props = {
note: TaskNote;
};
/**
* One note on an opened task: who wrote it (the user, or the bot by name),
* how long ago, and what it says.
*/
export default function TaskNoteRow({ note }: Props) {
const author = note.author == "user" ? "You" : note.bot_name || "Bot";
return (
<Stack className="w-full gap-0.5! items-stretch py-1.5 border-0 border-b border-solid border-hairline last:border-b-0">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="text-[12px] font-bold leading-4 text-ink">{author}</Span>
{note.created_at ? (
<Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(note.created_at) })}
</Span>
) : null}
</Row>
<Span className="whitespace-pre-wrap text-[13px] leading-[18px] text-ink">{note.text}</Span>
</Stack>
);
}
+14 -8
View File
@@ -1,3 +1,4 @@
import { twMerge } from "tailwind-merge";
import Tag from "@moduletrace/twui/elements/Tag";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
@@ -14,15 +15,20 @@ export default function TaskStatusChip({ status = "todo" }: Props) {
const value = (status || "todo") as TaskStatus;
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
const tone =
value == "blocked"
? "text-ink border-ink"
: value == "doing"
? "text-ink border-hairline-strong"
: "text-ink-secondary border-hairline";
/**
* A `Tag` is a filled primary button by default (ink in light mode), so
* the chip clears its fill and sets its own text: only the outline speaks.
*/
return (
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}>
<Tag
className={twMerge(
"shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid",
"bg-transparent! dark:bg-transparent! hover:bg-transparent!",
value == "blocked" && "text-ink! dark:text-ink! border-ink",
value == "doing" && "text-ink! dark:text-ink! border-hairline-strong",
value != "blocked" && value != "doing" && "text-ink-secondary! dark:text-ink-secondary! border-hairline",
)}
>
{title}
</Tag>
);
@@ -0,0 +1,28 @@
import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
value: string;
disabled?: boolean;
onMove: (status: string) => void;
};
/**
* Moves a task to one status: To do, Doing or Done.
*/
export default function TaskStatusMoveButton({ title, value, disabled, onMove }: Props) {
return (
<Button
title={`Mark it ${title.toLowerCase()}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={disabled}
onClick={() => onMove(value)}
className="px-1.5! text-[12px]"
>
{title}
</Button>
);
}
@@ -0,0 +1,36 @@
import Row from "@moduletrace/twui/layout/Row";
import { twMerge } from "tailwind-merge";
import { TaskStatuses } from "@/src/dict/task-dict";
import TaskStatusMoveButton from "./task-status-move-button";
type Props = {
/**
* The task's status now; its own button is left out.
*/
status?: string | null;
disabled?: boolean;
onMove: (status: string) => void;
className?: string;
};
const MoveStatuses = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
/**
* The buttons that move a task along (To do, Doing, Done), the one it's
* at left out. Shown to crew managers on a list row and in the task modal.
*/
export default function TaskStatusMoves({ status, disabled, onMove, className }: Props) {
return (
<Row className={twMerge("gap-1! flex-nowrap items-center", className)}>
{MoveStatuses.filter((it) => it.value != status).map((it) => (
<TaskStatusMoveButton
key={it.value}
title={it.title}
value={it.value}
disabled={disabled}
onMove={onMove}
/>
))}
</Row>
);
}
+12
View File
@@ -591,6 +591,18 @@ export const AppData = {
* inside its frame.
*/
BotGraphicInlineMaxHeight: 640,
/**
* Most questions one `ask_user` card holds.
*/
BotQuestionMaxQuestions: 6,
/**
* Most options one question offers (its "Other" not counted).
*/
BotQuestionMaxOptions: 8,
/**
* Longest answer a user may type into a question's "Other".
*/
BotQuestionOtherMaxChars: 1000,
/**
* Longest dictation the composer records before it stops on its own.
*/
+11 -3
View File
@@ -164,6 +164,14 @@ Anything you read or write goes to your model provider, so secret values (passwo
- Never print, cat or echo a secret on a computer. To show one to the user, use show_secret for an env you have, or in a task on a computer capture it with capture_secret: either gives you a [secret:ID] ref to put in your message.
- Use secrets by key ($KEY in the shell, {{env.KEY}} in tools), never by value. A value you see as [KEY] in output was redacted: that's expected.`;
/**
* Added to chat replies and to tasks the user started: before acting on
* something unclear, a bot asks (`ask_user`) rather than guesses.
*/
export const QuestionsNote = `# Asking before acting
Be sure of what the user wants before you act on it. When a request could mean more than one thing, when a choice is theirs to make, or before work that is hard to undo, costly or seen by others, and you can't tell what they'd pick, ask with ask_user: a card of multiple-choice questions (one pick or several, with "Other" for their own words) they answer in a click. Give options that are concrete and distinct, put the one you'd suggest first and say so in it ("Staging (suggested)"). Ask everything you need in one card, then wait for their answers before acting on what you asked. Don't ask about what they already said, what you can find out yourself, or small choices any sensible answer settles: just do those. If they skip the questions, don't guess: do only the part that's clear and say what you still need.`;
/**
* Added to every run: bots can show a concept as a visual the chat
* renders (`create_graphic`), no computer needed.
@@ -220,7 +228,7 @@ You are working through a task on your own; the user isn't watching. Use your to
*/
export const TasksNote = `# Tasks
Tasks are the crew's shared to-do list, one per bot and one per workspace. A workspace's tasks are seen by every bot working there, and the coordinator sees everything.
Tasks are the crew's to-do list. Every task belongs to one bot, on its own list or in a workspace. You see your own; a workspace's admin sees every task in that workspace, and the coordinator sees everything.
- list_tasks shows what's open: yours, a workspace's, or (as the coordinator) all of it. Check it before you plan work, so you don't start something already tracked.
- create_task adds one, with a brief: what to do, where, and what done looks like. Only add a task for work that will take more than this reply; never one for something you're doing now.
@@ -279,8 +287,8 @@ update_memory and update_instructions save to this workspace by default: what yo
export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text:
{"title": "...", "summary": "..."}
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps.
- summary: what the user wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. With no user message, it is work handed to you (by the coordinator, a schedule or a hook): name that work. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps.
- summary: what the user (or whoever handed you the work) wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
/**
* Asks the text model to carry a conversation into a new session when the
+15
View File
@@ -0,0 +1,15 @@
/**
* Where questions a bot asked a user stand (`bot_questions.status`):
* `pending` until the user answers them (`answered`) or skips them.
*/
export const BotQuestionStatuses = ["pending", "answered", "skipped"] as const;
export type BotQuestionStatus = (typeof BotQuestionStatuses)[number];
/**
* How a question is answered: `single` picks one option (radios),
* `multiple` any number of them, at least one (checkboxes).
*/
export const BotQuestionKinds = ["single", "multiple"] as const;
export type BotQuestionKind = (typeof BotQuestionKinds)[number];
+1
View File
@@ -110,6 +110,7 @@ export const JobStepToolLabels: Record<string, string> = {
hand_over_schedule: "Handing a routine to the crew",
propose_hook: "Proposing a hook",
request_secret: "Asking for a secret",
ask_user: "Asking you",
capture_secret: "Saving a secret",
show_secret: "Showing a secret",
pause_hook: "Pausing a hook",
+1
View File
@@ -58,6 +58,7 @@ export type CustomToolScriptLanguage = (typeof CustomToolScriptLanguages)[number
*/
export const ReservedToolNames = [
"add_bot_to_workspace",
"ask_user",
"ask_user_to_take_over",
"assign_computer",
"capture_secret",
+10 -1
View File
@@ -10,6 +10,7 @@ import {
LockKeyhole,
Monitor,
Plug,
Rocket,
Users,
SlidersHorizontal,
UserRound,
@@ -38,6 +39,8 @@ export type SettingsPage = {
export const SettingsComputersPath = "/admin/settings/computers";
export const SettingsBackupsPath = "/admin/settings/backups";
export const SettingsDatabasePath = "/admin/settings/database";
export const SettingsSetupPath = "/admin/settings/setup";
export const SettingsModelsPath = "/admin/settings/models";
/**
* The `/admin/settings/*` sub pages, listed in the rail on every settings
@@ -49,6 +52,12 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/general",
Icon: SlidersHorizontal,
},
{
title: "Setup",
url: SettingsSetupPath,
Icon: Rocket,
super_admin_only: true,
},
{ title: "Profile", url: "/admin/settings/profile", Icon: UserRound },
{
title: "Change password",
@@ -71,7 +80,7 @@ export const SettingsPages: SettingsPage[] = [
},
{
title: "Models",
url: "/admin/settings/models",
url: SettingsModelsPath,
Icon: CpuAi,
super_admin_only: true,
},
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* A task's status. Tasks are the crew's shared to-do list, one per bot and
* one per workspace, and every move through this list is a step in the
* A task's status. Tasks are the crew's to-do list, every task one bot's
* (on its own list or in a workspace), and every move through this list is a step in the
* work: `blocked` is what a bot (or the user) sets when only the user can
* unblock it, and `done` is only ever set once the work was verified.
*/
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
answers: { choices: number[]; other?: string }[];
};
/**
* The user answered questions a bot asked in its card: checked against the
* questions (`checkBotQuestionAnswers`), then stored once (only pending
* questions move, so a double click answers once).
*/
export default async function answerBotQuestion({ question_id, user_id, answers }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const { questions } = parseBotQuestionRow({ row });
const checked = checkBotQuestionAnswers({ questions, answers });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'answered', answers = ?, decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [JSON.stringify(checked), now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS, BunSQLiteTables } from "@/db/types/db";
import type { BotQuestionItem } from "@/src/types";
type Params = {
user_id: number | string;
bot_id: number | string;
session_id: number | string;
workspace_id?: number | string | null;
title: string;
questions: BotQuestionItem[];
};
/**
* Stores questions a bot asks the user, pending until they answer or skip
* them in its card. Returns its id.
*/
export default async function createBotQuestion({ user_id, bot_id, session_id, workspace_id, title, questions }: Params) {
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_QUESTIONS, (typeof BunSQLiteTables)[number]>({
table: "bot_questions",
data: [
{
title,
questions: JSON.stringify(questions),
status: "pending",
user_id: Number(user_id),
bot_id: Number(bot_id),
session_id: Number(session_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
},
],
});
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
if (!res.success || !id) throw new Error(`Couldn't store the questions: ${res.msg}`);
return id;
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotQuestionStatus } from "@/src/dict/bot-question-dict";
import type { BotQuestionView } from "@/src/types";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* Questions a bot asked the user as their chip and card show them: the
* questions, their options and, once answered, what the user picked.
*/
export default async function grabBotQuestionView({ question_id, user_id }: Params): Promise<BotQuestionView> {
const row = await grabBotQuestion({ question_id, user_id });
const { questions, answers } = parseBotQuestionRow({ row });
const bot = (
await BunSQLite.sql<{ name: string }>({
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
values: [row.bot_id || 0],
})
).payload?.[0];
return {
id: Number(row.id),
title: row.title || "",
questions,
answers,
status: (row.status || "pending") as BotQuestionStatus,
bot_id: Number(row.bot_id),
bot_name: bot?.name || "The bot",
};
}
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
question_id: number | string;
/**
* Only the user they were asked of reads them.
*/
user_id: number | string;
};
/**
* Questions a bot asked, only for the user they were asked of. Throws
* otherwise.
*/
export default async function grabBotQuestion({ question_id, user_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_QUESTIONS>({
sql: `SELECT * FROM bot_questions WHERE id = ? AND user_id = ? LIMIT 1`,
values: [Number(question_id), Number(user_id)],
})
).payload?.[0];
if (!row?.id) throw new AppError(`Those questions aren't yours or no longer exist.`);
return row;
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* The user skipped questions a bot asked: nothing is answered.
*/
export default async function skipBotQuestion({ question_id, user_id }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'skipped', decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
+9 -8
View File
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
import passOnBotTasks from "@/src/functions/backend/db/tasks/pass-on-bot-tasks";
import AppError from "@/src/utils/app-error";
import { clearEnvCache } from "../envs/grab-all-envs";
import grabUserBot from "./grab-user-bot";
@@ -40,21 +41,21 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
);
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
/**
* Its tasks in workspaces belong to the workspace: they go to each one's
* admin bot (else the coordinator) instead of cascading away with its
* own list.
*/
await passOnBotTasks({ bot_id: bot.id, bot_name: bot.name });
/**
* Explicitly, since foreign keys are off outside the server process.
*/
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
/**
* Its tasks in workspaces belong to the workspace: they stay, shared
* again, instead of cascading away with its own list.
*/
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE bot_id = ? AND workspace_id IS NOT NULL`,
values: [Date.now(), bot.id],
});
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_questions WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
@@ -0,0 +1,10 @@
import passOnBotTasks from "./pass-on-bot-tasks";
/**
* At boot: every task belongs to a bot. Workspace tasks left without one
* by earlier versions ("shared") go to the workspace's admin bot, else the
* coordinator.
*/
export default async function assignOwnerlessTasks() {
return await passOnBotTasks({});
}
@@ -27,7 +27,7 @@ type Params = {
/**
* Adds a task to a bot's own list or a workspace's, there for one of its
* bots (`bot_id` too) or shared. A user adds one to a bot they chat with or
* bots (`bot_id` too): every task belongs to a bot. A user adds one to a bot they chat with or
* a workspace they're in; a bot's run adds one to its own list or to a
* workspace it works in (its tool checks that). A workspace's task can
* only go to a bot working there. Caps a list at `TaskMaxPerScope` open and
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabWorkspaceAdmins from "../bot-types/grab-workspace-admins";
type Params = {
workspace_id: number | string;
/**
* The bot leaving, never its own heir.
*/
except_bot_id?: number | string | null;
};
/**
* Who takes over a workspace task whose bot is gone: the workspace's admin
* bot, else the coordinator. Every task belongs to a bot, so none is left
* without one.
*/
export default async function grabTaskHeir({ workspace_id, except_bot_id }: Params) {
const admins = await grabWorkspaceAdmins({ workspace_id });
const admin = admins.find((bot) => Number(bot.id) != Number(except_bot_id || 0));
if (admin?.id) return admin;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE kind = 'coordinator' ORDER BY id ASC LIMIT 1`,
});
return res.payload?.[0] || null;
}
+12 -6
View File
@@ -1,6 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_NOTES } from "@/db/types/db";
import type { TaskDetail } from "@/src/types";
import type { TaskDetail, TaskNote } from "@/src/types";
import AppError from "@/src/utils/app-error";
import botFaceSql from "@/src/utils/bot-face-sql";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
@@ -16,7 +15,8 @@ type Params = {
};
/**
* One task with its notes (oldest first) and files, only if the user
* One task with its notes (oldest first, each with the bot that wrote it),
* its files and who added it, only if the user
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same
* condition the lists use).
*/
@@ -26,10 +26,14 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
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, b.color AS bot_color, b.stone AS bot_stone,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
cb.name AS created_by_bot_name,
NULLIF(TRIM(COALESCE(cu.first_name, '') || ' ' || COALESCE(cu.last_name, '')), '') AS created_by_user_name
FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id
LEFT JOIN workspaces w ON w.id = t.workspace_id
LEFT JOIN bots cb ON cb.id = t.created_by_bot_id
LEFT JOIN users cu ON cu.id = t.created_by_user_id
WHERE t.id = ? AND ${access.sql}
LIMIT 1`,
values: [...face.values, Number(task_id), ...access.values],
@@ -42,8 +46,10 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
}
const [notes_res, files] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({
sql: `SELECT * FROM task_notes WHERE task_id = ? ORDER BY id ASC`,
BunSQLite.sql<TaskNote>({
sql: `SELECT n.*, nb.name AS bot_name FROM task_notes n
LEFT JOIN bots nb ON nb.id = n.bot_id
WHERE n.task_id = ? ORDER BY n.id ASC`,
values: [Number(task_id)],
}),
grabTaskMedia({ task_id }),
+1 -6
View File
@@ -27,10 +27,6 @@ type Params = {
* Only the tasks this bot has (anywhere, unless narrowed).
*/
only_bot_id?: number | string | null;
/**
* With `only_bot_id`: also the shared ones nobody has yet.
*/
with_unassigned?: boolean;
/**
* Only one workspace's tasks.
*/
@@ -61,7 +57,6 @@ export default async function grabTasks({
is_coordinator,
admin_workspace_ids,
only_bot_id,
with_unassigned,
only_workspace_id,
own_list,
statuses,
@@ -82,7 +77,7 @@ export default async function grabTasks({
}
if (only_bot_id) {
where.push(with_unassigned ? `(t.bot_id = ? OR t.bot_id IS NULL)` : `t.bot_id = ?`);
where.push(`t.bot_id = ?`);
values.push(Number(only_bot_id));
}
+1 -1
View File
@@ -10,7 +10,7 @@ type Params = {
task_id: number | string;
/**
* Where it goes: a bot's own list (`bot_id` alone), or a workspace,
* with the bot that has it there (none: unassigned).
* with the bot that has it there (always one).
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
@@ -0,0 +1,65 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import addTaskNote from "./add-task-note";
import grabTaskHeir from "./grab-task-heir";
type Params = {
/**
* The bot leaving; none for tasks that have no bot at all.
*/
bot_id?: number | string | null;
/**
* Only its tasks in this workspace; none for every workspace.
*/
workspace_id?: number | string | null;
/**
* Who is leaving, for the note on each task.
*/
bot_name?: string | null;
};
/**
* Gives a leaving bot's workspace tasks (or ones with no bot) to the
* workspace's admin bot, else the coordinator, with a note on each. Its own
* list is left alone. Returns how many moved.
*/
export default async function passOnBotTasks({ bot_id, workspace_id, bot_name }: Params) {
const where = [`workspace_id IS NOT NULL`, bot_id ? `bot_id = ?` : `bot_id IS NULL`];
const values: number[] = bot_id ? [Number(bot_id)] : [];
if (workspace_id) {
where.push(`workspace_id = ?`);
values.push(Number(workspace_id));
}
const tasks =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT * FROM tasks WHERE ${where.join(" AND ")}`,
values,
})
).payload || [];
let moved = 0;
for (const task of tasks) {
const heir = await grabTaskHeir({ workspace_id: task.workspace_id!, except_bot_id: bot_id });
if (!heir?.id) continue;
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = ?, updated_at = ? WHERE id = ?`,
values: [Number(heir.id), Date.now(), Number(task.id)],
});
await addTaskNote({
task_id: task.id!,
text: bot_name ? `${bot_name} left; this is now ${heir.name}'s.` : `This is now ${heir.name}'s.`,
author: "user",
}).catch(() => {});
moved++;
}
return moved;
}
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import taskScopeValues from "./task-scope-values";
describe("taskScopeValues", () => {
test("a task on a bot's own list or in a workspace has its bot", () => {
expect(taskScopeValues({ bot_id: 3 })).toEqual({ bot_id: 3, workspace_id: null });
expect(taskScopeValues({ bot_id: "3", workspace_id: "11" })).toEqual({ bot_id: 3, workspace_id: 11 });
});
test("a workspace task without a bot is refused", () => {
expect(() => taskScopeValues({ workspace_id: 11 })).toThrow();
expect(() => taskScopeValues({})).toThrow();
});
});
@@ -6,17 +6,17 @@ type Params = {
};
/**
* Where a task lives and who has it. A bot's own list is `bot_id` alone; a
* workspace's task has `workspace_id`, plus `bot_id` when a bot there has
* it (none: shared, not yet anyone's). Checked in the helpers rather than
* the schema.
* Where a task lives and who has it. Every task belongs to a bot: a bot's
* own list is `bot_id` alone, a workspace's task has `workspace_id` plus
* the `bot_id` that has it there. Checked in the helpers rather than the
* schema.
*/
export default function taskScopeValues({ bot_id, workspace_id }: Params) {
const bot = bot_id ? Number(bot_id) : null;
const workspace = workspace_id ? Number(workspace_id) : null;
if (!bot && !workspace) {
throw new AppError(`Say which bot or workspace the task is for.`);
if (!bot) {
throw new AppError(`Say which bot has the task.`);
}
return { bot_id: bot, workspace_id: workspace };
@@ -74,7 +74,7 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
/**
* These point at the user without a foreign key, so nothing cascades.
*/
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_graphics"]) {
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_questions", "bot_graphics"]) {
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
}
@@ -10,8 +10,8 @@ type Params = {
/**
* The user's latest conversations in a workspace, most recent first, each
* with the bot it's with (its face as the user sees it), its message count and latest message. Empty
* sessions are left out unless active.
* with the bot it's with (its face as the user sees it), its message count, first and latest message. Sessions
* with no messages yet are left out, active or not.
*/
export default async function grabWorkspaceActivity({
user_id,
@@ -28,11 +28,12 @@ export default async function grabWorkspaceActivity({
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
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at ASC, m.id ASC LIMIT 1) AS first_message_text
FROM bot_sessions s
JOIN bots b ON b.id = s.bot_id
WHERE s.workspace_id = ? AND s.user_id = ? AND s.chatroom_id IS NULL
AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))
AND EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id)
AND (b.kind = 'coordinator' OR EXISTS (
SELECT 1 FROM workspace_bots wb WHERE wb.workspace_id = s.workspace_id AND wb.bot_id = s.bot_id
))
@@ -1,6 +1,8 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
import grabBotById from "../bots/grab-bot-by-id";
import passOnBotTasks from "../tasks/pass-on-bot-tasks";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
@@ -31,12 +33,11 @@ export default async function removeBotFromWorkspace({
});
/**
* The tasks it had there stay in the workspace, shared again.
* The tasks it had there stay in the workspace, passed to its admin bot
* (else the coordinator), read before its admin type goes below.
*/
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
values: [Date.now(), workspace.id, bot_id],
});
const bot = await grabBotById({ bot_id });
await passOnBotTasks({ bot_id, workspace_id: workspace.id, bot_name: bot?.name });
/**
* Its types there (workspace admin) go with its place.
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import askUserTool from "./ask-user-tool";
const tool = askUserTool({ user_id: 1, bot_id: 2, session_id: 3, onAsk: () => {} });
const question = { question: "Which environment?", kind: "single", options: ["Staging", "Production"] };
describe("askUserTool", () => {
test("takes a set of questions", () => {
expect(tool.schema?.safeParse({ title: "Before I deploy", questions: [question] }).success).toBe(true);
});
test("needs at least two distinct options", () => {
expect(tool.schema?.safeParse({ questions: [{ ...question, options: ["Staging"] }] }).success).toBe(false);
expect(tool.schema?.safeParse({ questions: [{ ...question, options: ["Staging", "staging"] }] }).success).toBe(false);
});
test("needs a known kind and at least one question", () => {
expect(tool.schema?.safeParse({ questions: [{ ...question, kind: "ranked" }] }).success).toBe(false);
expect(tool.schema?.safeParse({ questions: [] }).success).toBe(false);
});
test("caps how many questions one card holds", () => {
expect(tool.schema?.safeParse({ questions: Array.from({ length: 7 }, () => question) }).success).toBe(false);
});
});
@@ -0,0 +1,81 @@
import { z } from "zod";
import createBotQuestion from "@/src/functions/backend/db/bot-questions/create-bot-question";
import { BotQuestionItemsSchema } from "@/src/schemas/bot-question-schema";
import botQuestionRef from "@/src/utils/bot-question-ref";
import type { HarnessTool } from "../../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The conversation they're asked in, where their card shows.
*/
session_id: number | string;
workspace_id?: number | string | null;
/**
* Whether the run is a task, which stops and reports rather than waits.
*/
in_task?: boolean;
/**
* Called with the questions' ref, so the message always carries their
* card.
*/
onAsk: (params: { ref: string }) => void;
};
type Args = {
title?: string;
questions: z.infer<typeof BotQuestionItemsSchema>;
};
/**
* Lets a bot make sure of what the user wants before it acts: it asks a
* few multiple-choice questions, the user answers them in a card (radios
* for one pick, checkboxes for several, "Other" to type their own), and
* the answers come back as the user's next message. Once per run, so every
* question goes on one card.
*/
export default function askUserTool({ user_id, bot_id, session_id, workspace_id, in_task, onAsk }: Params): HarnessTool<Args> {
let asked = false;
return {
name: "ask_user",
description: `Ask the user multiple-choice questions to be sure what they want before you act, shown as a card in the chat they answer by picking options. Use it when the request is ambiguous or open to more than one reading, when a choice is theirs to make (which target, which items, how far to go), or before something that is hard to undo, costly or visible to others, and you can't tell what they'd pick. Don't ask what they already told you, what you can find out yourself, or what any sensible choice would settle. Put every question in one call (at most one per reply). Their answers arrive as their next message${
in_task ? `. In a task, ask, then stop and report that you're waiting for their answers.` : `: after asking, end your reply and wait; don't act on what you asked about until they answer.`
}`,
schema: z.object({
title: z.string().trim().max(120).optional().describe("A short heading for the card, e.g. \"Before I deploy\""),
questions: BotQuestionItemsSchema.describe("The questions, in the order to answer them"),
}),
run: async ({ args }) => {
if (asked) {
throw new Error(`You already asked the user in this run. Put every question in one ask_user call, then wait for their answers.`);
}
const id = await createBotQuestion({
user_id,
bot_id,
session_id,
workspace_id,
title: args.title?.trim() || "",
questions: args.questions.map((item) => ({
question: item.question,
kind: item.kind,
options: item.options,
allow_other: Boolean(item.allow_other),
})),
});
asked = true;
onAsk({ ref: botQuestionRef({ id }) });
const count = args.questions.length;
return `Asked the user ${count} question${count == 1 ? "" : "s"}: the card is on your message. ${
in_task
? `Stop here and report that you're waiting for their answers.`
: `End your reply now with a short line saying what you need settled; their answers arrive as their next message.`
} Don't repeat the questions or options in your text.`;
},
};
}
+16 -27
View File
@@ -39,19 +39,18 @@ type Args = {
description: string;
bot_id?: number;
workspace_id?: number;
shared?: boolean;
status?: TaskStatus;
};
type Place = { bot_id?: number; workspace_id?: number };
type Place = { bot_id: number; workspace_id?: number };
/**
* Worked out from the arguments and who is asking: where the task goes and
* who has it, or why it can't. Outside a workspace a worker adds only to
* its own list. In one the task stays in that workspace: its own by
* default, a member's as the workspace's admin, or shared (nobody's yet).
* The coordinator adds to any worker's own list or any workspace, for one
* of its bots or shared.
* who has it, or why it can't. Every task belongs to a bot. Outside a
* workspace a worker adds only to its own list. In one the task stays in
* that workspace: its own by default, or a member's as the workspace's
* admin. The coordinator adds to any worker's own list or any workspace,
* for one of its bots or itself.
*/
function resolvePlace({
args,
@@ -69,22 +68,18 @@ function resolvePlace({
const target_bot = args.bot_id ? Number(args.bot_id) : null;
const target_workspace = args.workspace_id ? Number(args.workspace_id) : null;
if (args.shared && target_bot) {
return `A shared task is nobody's yet: leave bot_id out, or drop shared to give it to that bot.`;
}
if (is_coordinator) {
const place_workspace = target_workspace || (workspace_id ? Number(workspace_id) : null);
if (place_workspace) {
return target_bot ? { workspace_id: place_workspace, bot_id: target_bot } : { workspace_id: place_workspace };
return { workspace_id: place_workspace, bot_id: target_bot || Number(bot_id) };
}
return { bot_id: target_bot || Number(bot_id) };
}
if (!workspace_id) {
if (target_workspace || args.shared) {
if (target_workspace) {
return `You're not in a workspace here, so a task can only go on your own list (bot_id ${bot_id}).`;
}
@@ -99,14 +94,12 @@ function resolvePlace({
return `You can only add tasks in this workspace (#${workspace_id}).`;
}
if (args.shared) return { workspace_id: Number(workspace_id) };
const is_member = members.some((member) => member.id == target_bot);
if (target_bot && target_bot != Number(bot_id) && !is_member) {
return members.length
? `You can only give a task here to yourself (#${bot_id}) or a member (${members.map((m) => m.id).join(", ")}), or leave it shared.`
: `You can only give a task here to yourself (#${bot_id}), or leave it shared.`;
? `You can only give a task here to yourself (#${bot_id}) or a member (${members.map((m) => m.id).join(", ")}).`
: `You can only give a task here to yourself (#${bot_id}).`;
}
return { workspace_id: Number(workspace_id), bot_id: target_bot || Number(bot_id) };
@@ -114,8 +107,8 @@ function resolvePlace({
/**
* Lets a bot add a task to a list it works on: its own, or in a workspace
* its own there (a member's as its admin) or a shared one. The coordinator
* can add to any worker's list and any workspace.
* its own there (a member's as its admin). The coordinator can add to any
* worker's list and any workspace. Every task belongs to a bot.
*/
export default function createTaskTool({
user_id,
@@ -134,8 +127,8 @@ export default function createTaskTool({
return {
name: "create_task",
description: is_coordinator
? `Add a task: to a worker's own list (bot_id: ${crew || "no workers yet"}), to a workspace (workspace_id: ${places || "no workspaces yet"}) for one of its bots (with bot_id) or shared (without), or to your own list (leave both ids out${workspace_id ? "; here, leaving them out makes it a shared task in this workspace" : ""}). Write the description as a brief: what to do, where, and how you'll know it's done.`
: `Add a task: ${workspace_id ? `in this workspace, yours by default${team ? `, a member's as this workspace's admin (bot_id: ${team})` : ""}, or shared (shared: true) for whichever bot here takes it up` : `on your own list (bot_id ${bot_id}, the default)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
? `Add a task: to a worker's own list (bot_id: ${crew || "no workers yet"}), to a workspace (workspace_id: ${places || "no workspaces yet"}) for one of its bots (with bot_id; without, it's yours there), or to your own list (leave both ids out${workspace_id ? "; here, leaving them out makes it yours in this workspace" : ""}). Every task belongs to one bot. Write the description as a brief: what to do, where, and how you'll know it's done.`
: `Add a task: ${workspace_id ? `in this workspace, yours by default${team ? `, or a member's as this workspace's admin (bot_id: ${team})` : ""}` : `on your own list (bot_id ${bot_id}, the default)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
schema: z.object({
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
description: z
@@ -144,7 +137,7 @@ export default function createTaskTool({
.describe(`The brief, in markdown: what to do, where, and what done looks like`),
bot_id: z.number().int().optional().describe(
is_coordinator
? `Who has it: a worker's own list, or with workspace_id a bot working there. Leave out for your own list or a shared workspace task.`
? `Who has it: a worker's own list, or with workspace_id a bot working there. Leave out to have it yourself.`
: team
? `Who has it here: you (${bot_id}, the default) or a member (${team}).`
: `Your own id (${bot_id}); leaving it out has the same effect.`,
@@ -158,10 +151,6 @@ export default function createTaskTool({
? `The workspace it's in, when it isn't a bot's own list.`
: `This workspace (${workspace_id || "none"}); leaving it out has the same effect.`,
),
shared: z
.boolean()
.optional()
.describe(`In a workspace: nobody's yet, for whichever bot there takes it up. Defaults to false.`),
status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`),
}),
run: async ({ args }) => {
@@ -201,7 +190,7 @@ export default function createTaskTool({
});
const where = place.workspace_id
? `in workspace ${place.workspace_id}, ${!place.bot_id ? "shared" : place.bot_id == Number(bot_id) ? "yours" : `bot ${place.bot_id}'s`}`
? `in workspace ${place.workspace_id}, ${place.bot_id == Number(bot_id) ? "yours" : `bot ${place.bot_id}'s`}`
: place.bot_id == Number(bot_id)
? "to your list"
: `to bot ${place.bot_id}'s list`;
@@ -25,8 +25,8 @@ type Params = {
/**
* The task tools every run gets (`src/harness/tools/bot/tasks/`): the
* to-do list (a bot's own, and in each workspace its tasks there and the
* shared ones), so a chat reply, a task and a scheduled run all read it,
* to-do list (every task belongs to one bot: its own list and its tasks in
* each workspace; an admin's whole workspace), so a chat reply, a task and a scheduled run all read it,
* move it along and file what they produced. The coordinator's create_task
* may target any worker or workspace; its update_task may link any file it
* can reach. A workspace admin's may give tasks there to the other members.
@@ -27,14 +27,15 @@ type Args = {
};
/**
* Lets a bot read the tasks it can work on: its own list and every
* workspace it works in, or for the coordinator the whole install. Used
* Lets a bot read the tasks it can work on: its own (its list and what it
* has in workspaces), every task of a workspace it's admin of, or for the
* coordinator the whole install. Used
* both to plan work and to remind the user of what's pending.
*/
export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool<Args> {
return {
name: "list_tasks",
description: `List tasks: yours (your own list and the ones you have in workspaces), the ones in ${workspace_id ? "this workspace" : "a workspace"}, or ${is_coordinator ? "every task in the install" : "every task you can reach (yours, and in your workspaces the shared ones nobody has yet)"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
description: `List tasks: yours (your own list and the ones you have in workspaces), the ones in ${workspace_id ? "this workspace" : "a workspace"}, or ${is_coordinator ? "every task in the install" : "every task you can reach (yours, and as a workspace's admin every task there)"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
schema: z.object({
scope: z
.enum(["mine", "workspace", "all"])
@@ -74,7 +75,7 @@ export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_w
return tasks
.map((task) => {
const who = task.bot_id ? task.bot_name || `bot ${task.bot_id}` : "shared, nobody's yet";
const who = task.bot_name || `bot ${task.bot_id}`;
const where = task.workspace_id
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}, ${who}`
: `on ${who}'s list`;
@@ -4,7 +4,6 @@ import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
import moveTask from "@/src/functions/backend/db/tasks/move-task";
import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media";
import updateTask from "@/src/functions/backend/db/tasks/update-task";
import type { StorageScope } from "@/src/utils/storage-access";
@@ -41,7 +40,7 @@ type Args = {
export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool<Args> {
return {
name: "update_task",
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Setting a shared workspace task to doing makes it yours. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
schema: z.object({
task_id: z.number().int(),
status: z.enum(TaskStatusValues).optional(),
@@ -68,15 +67,6 @@ export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_
const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator, admin_workspace_ids });
const said: string[] = [];
/**
* A shared task in a workspace is nobody's until a bot starts on
* it: then it's that bot's.
*/
if (args.status == "doing" && task.workspace_id && !task.bot_id && !is_coordinator) {
await moveTask({ task_id: task.id!, bot_id, workspace_id: task.workspace_id });
said.push(`it's yours now`);
}
if (args.status || args.blocked_reason !== undefined) {
if (args.status == "blocked" && !args.blocked_reason?.trim()) {
return `Blocked needs a reason: say in blocked_reason what the user has to do.`;
@@ -42,7 +42,7 @@ export const MessageMarkdownComponents: Components = {
</MarkdownList>
),
code: ({ children }) => (
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink break-words">
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink wrap-anywhere">
{children}
</Span>
),
@@ -58,7 +58,7 @@ export const MessageMarkdownComponents: Components = {
),
table: ({ children }) => (
<Stack className="w-full max-w-full overflow-x-auto items-start">
<table className="text-[13px] leading-5 text-ink border-collapse">{children}</table>
<table className="text-[13px] leading-5 text-ink border-collapse wrap-break-word">{children}</table>
</Stack>
),
th: ({ children }) => (
@@ -39,7 +39,7 @@ export const UserMarkdownComponents: Components = {
</MarkdownList>
),
code: ({ children }) => (
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink break-words">
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink wrap-anywhere">
{children}
</Span>
),
@@ -0,0 +1,22 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BotQuestionAnswer, BotQuestionView } from "@/src/types";
type Params = {
question_id: number;
/**
* One per question; none skips them.
*/
answers?: BotQuestionAnswer[];
};
/**
* Answers questions a bot asked from their card, or skips them, returning
* the questions as they now stand.
*/
export default async function decideBotQuestion({ question_id, answers }: Params) {
return await fetchApi<{ question_id: number; answers?: BotQuestionAnswer[] }, APIResponseObject<BotQuestionView>>(
`/api/admin/bot-questions/${answers ? "answer" : "skip"}-question`,
{ method: "POST", body: answers ? { question_id, answers } : { question_id } },
);
}
@@ -0,0 +1,65 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { BotQuestionView } from "@/src/types";
type Params = {
question_id: number;
};
/**
* Questions already read, by id, so a thread naming them twice asks once.
* An answer replaces their entry.
*/
const loaded = new Map<number, Promise<BotQuestionView | null>>();
function loadQuestion({ question_id }: Params) {
const cached = loaded.get(question_id);
if (cached) return cached;
const pending = fetchApi<{}, APIResponseObject<BotQuestionView>>(
`/api/admin/bot-questions/get-question?question_id=${question_id}`,
{ method: "GET" },
)
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
.catch(() => null);
loaded.set(question_id, pending);
return pending;
}
/**
* Questions a bot asked (`[question:ID]`), for their chip and card: read
* when the chip renders, so it shows whether they're still waiting. Only
* the user they were asked of can read them; anyone else gets null.
*/
export default function useBotQuestion({ question_id }: Params) {
const [view, setViewState] = useState<BotQuestionView | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let live = true;
loadQuestion({ question_id }).then((result) => {
if (!live) return;
setViewState(result);
setLoading(false);
});
return () => {
live = false;
};
}, [question_id]);
const setView = useCallback(
(next: BotQuestionView) => {
loaded.set(question_id, Promise.resolve(next));
setViewState(next);
},
[question_id],
);
return { view, loading, setView };
}
@@ -15,7 +15,7 @@ type Params = {
/**
* A conversation's tasks: a bot's own list, or in a workspace the ones it
* has there plus the shared ones nobody has yet. Loaded on open and refreshed after every change,
* has there. Loaded on open and refreshed after every change,
* never part of the page props.
*/
export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) {
@@ -65,7 +65,7 @@ function BotMessageLine({ message, bot_name, at }: Props) {
) : null}
<Stack
{...{ [QuotableAttribute]: "" }}
className="w-full gap-2.5! items-start break-words"
className="w-full gap-2.5! items-start wrap-anywhere [&>*]:max-w-full"
>
<Markdown
remarkPlugins={RemarkPlugins}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import type { BotQuestionView } from "@/src/types";
import BotQuestionCard from "./bot-question-card";
const deploy: BotQuestionView = {
id: 4,
title: "Before I deploy",
questions: [
{ question: "Which environment?", kind: "single", options: ["Staging (suggested)", "Production"], allow_other: true },
{ question: "Which checks should run first?", kind: "multiple", options: ["Lint", "Tests"], allow_other: false },
],
answers: null,
status: "pending",
bot_id: 6,
bot_name: "Ada",
};
function render({ view }: { view: BotQuestionView }) {
return renderToString(<BotQuestionCard view={view} onDecided={() => {}} onDone={() => {}} />);
}
describe("BotQuestionCard", () => {
test("asks each question with radios for one pick and checkboxes for several", () => {
const html = render({ view: deploy });
expect(html).toContain("Which environment?");
expect(html).toContain(`type="radio"`);
expect(html).toContain("Staging (suggested)");
expect(html).toContain(">Other<");
expect(html).toContain("twui-checkbox");
expect(html).toContain("Pick any");
expect(html).toContain(">Send answers<");
});
test("once answered, shows what was picked and offers no inputs", () => {
const html = render({
view: {
...deploy,
status: "answered",
answers: [
{ choices: [], other: "QA box" },
{ choices: [0, 1], other: "" },
],
},
});
expect(html).not.toContain(`type="radio"`);
expect(html).not.toContain(">Send answers<");
expect(html).toContain("Other: QA box");
expect(html).toContain("Lint; Tests");
});
test("once skipped, says so", () => {
expect(render({ view: { ...deploy, status: "skipped" } })).toContain("Nothing was answered");
});
});
@@ -0,0 +1,137 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import type { BotQuestionView } from "@/src/types";
import botQuestionAnswersText from "@/src/utils/bot-question-answers-text";
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
import decideBotQuestion from "../(functions)/decide-bot-question";
import sendBotMessage from "../(functions)/send-bot-message";
import botQuestionDraftAnswer, { type BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
import BotQuestionField from "./bot-question-field";
type Props = {
view: BotQuestionView;
onDecided: (view: BotQuestionView) => void;
onDone: () => void;
};
/**
* The card a user answers a bot's questions in: each question with its
* options (radios for one pick, checkboxes for several, "Other" to type
* their own). Sending or skipping tells the bot in chat, as the user's
* message; once decided it shows what they answered.
*/
export default function BotQuestionCard({ view, onDecided, onDone }: Props) {
const { pageProps } = useContext(AppContext);
const [drafts, setDrafts] = useState<BotQuestionDraft[]>(() =>
view.questions.map(() => ({ choices: [], other_on: false, other: "" })),
);
const [busy, setBusy] = useState<"answer" | "skip">();
const [error, setError] = useState<string>();
const open = view.status == "pending";
const setDraft = ({ index, draft }: { index: number; draft: BotQuestionDraft }) => {
setError(undefined);
setDrafts((current) => current.map((item, i) => (i == index ? draft : item)));
};
const decide = async (decision: "answer" | "skip") => {
const answers = drafts.map((draft) => botQuestionDraftAnswer({ draft }));
if (decision == "answer") {
try {
checkBotQuestionAnswers({ questions: view.questions, answers });
} catch (e: unknown) {
setError(e instanceof Error ? e.message : `Answer every question.`);
return;
}
}
const question =
decision == "answer"
? `Send your answers to ${view.bot_name}?`
: `Skip these questions? ${view.bot_name} is told you didn't answer them.`;
if (!window.confirm(question)) return;
setBusy(decision);
setError(undefined);
const res = await decideBotQuestion({
question_id: view.id,
answers: decision == "answer" ? answers : undefined,
}).catch(() => null);
if (!res?.success || !res.singleRes) {
setBusy(undefined);
setError(res?.msg || `Couldn't ${decision == "answer" ? "send your answers" : "skip them"}.`);
return;
}
onDecided(res.singleRes);
await sendBotMessage({
bot_id: Number(pageProps?.bot?.id) || 0,
workspace_id: pageProps?.workspace?.id || null,
text: botQuestionAnswersText({ view: res.singleRes }),
media_ids: [],
}).catch(() => null);
setBusy(undefined);
onDone();
};
return (
<Stack className="w-full gap-0! items-stretch">
{view.questions.map((item, index) => (
<BotQuestionField
key={index}
question_id={view.id}
index={index}
item={item}
draft={drafts[index]!}
answer={view.answers?.[index] || null}
open={open}
onChange={(draft) => setDraft({ index, draft })}
/>
))}
{view.status == "skipped" ? (
<Span className="mt-4 text-[13px] leading-5 text-ink">Skipped. Nothing was answered.</Span>
) : null}
{error ? <Span className="mt-3 text-[13px] leading-5 text-ink">{error}</Span> : null}
{open ? (
<Row className="mt-5 w-full gap-2! justify-end">
<Button
type="button"
variant="outlined"
color="gray"
size="small"
title="Skip: the bot is told you didn't answer"
loading={busy == "skip"}
disabled={Boolean(busy)}
onClick={() => decide("skip")}
>
Skip
</Button>
<Button
type="button"
size="small"
title="Send your answers"
loading={busy == "answer"}
disabled={Boolean(busy)}
onClick={() => decide("answer")}
>
Send answers
</Button>
</Row>
) : null}
</Stack>
);
}
@@ -0,0 +1,112 @@
import Input from "@moduletrace/twui/form/Input";
import Radios from "@moduletrace/twui/form/Radios";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
import type { BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
import BotQuestionOption from "./bot-question-option";
import MessageDetailsHeading from "./message-details-heading";
type Props = {
question_id: number;
index: number;
item: BotQuestionItem;
draft: BotQuestionDraft;
/**
* What the user answered, once they did.
*/
answer: BotQuestionAnswer | null;
/**
* Whether it still takes answers.
*/
open: boolean;
onChange: (draft: BotQuestionDraft) => void;
};
/**
* Value of the "Other" radio, beside the options' indexes.
*/
const OtherValue = "other";
/**
* One of a bot's questions in its card: the question, then its options as
* radios (one pick) or checkboxes (several), with "Other" and a field to
* type into when the bot allows it. Once answered, what the user picked.
*/
export default function BotQuestionField({ question_id, index, item, draft, answer, open, onChange }: Props) {
const single = item.kind == "single";
const picked = answer
? [...answer.choices.map((choice) => item.options[choice]), answer.other ? `Other: ${answer.other}` : ""].filter(Boolean)
: [];
return (
<Stack className="w-full gap-0! items-stretch">
<MessageDetailsHeading>{`${index + 1}. ${single ? "Pick one" : "Pick any"}`}</MessageDetailsHeading>
<Span className="whitespace-pre-wrap text-[13px] leading-5 font-bold text-ink break-words">{item.question}</Span>
{open ? (
<Stack className="mt-2 w-full gap-2! items-stretch">
{single ? (
<Radios
name={`question-${question_id}-${index}`}
values={[
...item.options.map((option, choice) => ({ value: String(choice), title: option })),
...(item.allow_other ? [{ value: OtherValue, title: "Other" }] : []),
]}
wrapperProps={{ className: "w-full flex-col items-start gap-2!" }}
labelProps={{ className: "text-[13px] leading-5 text-ink break-words cursor-pointer" }}
changeHandler={(value) =>
onChange(
value == OtherValue
? { ...draft, choices: [], other_on: true }
: { ...draft, choices: [Number(value)], other_on: false },
)
}
/>
) : (
<Stack className="w-full gap-2! items-stretch">
{item.options.map((option, choice) => (
<BotQuestionOption
key={choice}
label={option}
checked={draft.choices.includes(choice)}
onChange={(checked) =>
onChange({
...draft,
choices: checked
? [...new Set([...draft.choices, choice])]
: draft.choices.filter((c) => c != choice),
})
}
/>
))}
{item.allow_other ? (
<BotQuestionOption
label="Other"
checked={draft.other_on}
onChange={(checked) => onChange({ ...draft, other_on: checked })}
/>
) : null}
</Stack>
)}
{draft.other_on ? (
<Input
id={`question-${question_id}-${index}-other`}
title="Your answer"
placeholder="Your own answer"
maxLength={AppData.BotQuestionOtherMaxChars}
value={draft.other}
onChange={(e) => onChange({ ...draft, other: e.target.value })}
/>
) : null}
</Stack>
) : answer ? (
<Span className="mt-1 whitespace-pre-wrap text-[13px] leading-5 text-ink-secondary break-words">
{picked.join("; ")}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,26 @@
import Checkbox from "@moduletrace/twui/form/Checkbox";
type Props = {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
};
/**
* One option of a multiple-choice question in a bot's question card.
*/
export default function BotQuestionOption({ label, checked, onChange }: Props) {
return (
<Checkbox
title={label}
label={label}
size={18}
checked={checked}
changeHandler={(value) => {
if (value != checked) onChange(value);
}}
wrapperClassName="flex-nowrap! md:flex-nowrap items-start! cursor-pointer"
labelProps={{ className: "whitespace-normal md:whitespace-normal text-[13px] leading-5 text-ink break-words" }}
/>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageCircleQuestion } from "lucide-react";
import useBotQuestion from "../(hooks)/use-bot-question";
import BotQuestionCard from "./bot-question-card";
type Props = {
question_id: number;
};
const StatusLabels = {
pending: "waiting for you",
answered: "answered",
skipped: "skipped",
} as const;
/**
* Questions a bot asked before acting (`botQuestionRef`): a chip naming
* them and where they stand, which opens the card the user answers them
* in. Anyone they weren't asked of sees their number only.
*/
export default function BotQuestionRefChip({ question_id }: Props) {
const { view, loading, setView } = useBotQuestion({ question_id });
const [open, setOpen] = useState(false);
if (!view) {
return (
<Span className="font-bold">
{loading ? `questions ${question_id}` : `questions ${question_id} (not available)`}
</Span>
);
}
const count = view.questions.length;
const name = view.title || (count == 1 ? "1 question" : `${count} questions`);
const status = StatusLabels[view.status];
const pending = view.status == "pending";
return (
<Modal
target={
<Button
type="button"
variant={pending ? undefined : "outlined"}
color={pending ? undefined : "gray"}
size="smaller"
title={pending ? `Answer ${name}` : `${name}: ${status}`}
className={twMerge(
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap",
!pending && "text-ink",
"[&_.twui-button-content-wrapper]:min-w-0",
)}
>
<MessageCircleQuestion size={14} aria-hidden className="shrink-0" />
<Span className="min-w-0 truncate">{`${name} · ${status}`}</Span>
</Button>
}
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{view.title || `${view.bot_name} asks before going on`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{pending
? `${view.bot_name} wants to be sure what you want before it acts. Your answers go to it as your message.`
: `What ${view.bot_name} asked before going on.`}
</Span>
</Stack>
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
<BotQuestionCard view={view} onDecided={setView} onDone={() => setOpen(false)} />
</Stack>
</Modal>
);
}
@@ -35,7 +35,7 @@ export default function BotRefChip({ bot_id }: Props) {
: `/admin/bots/${bot.id}`
}
title={`Open ${bot.name}`}
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
>
<BotAvatar
image_media_id={bot.image_media_id}
@@ -43,9 +43,10 @@ export default function BotRefChip({ bot_id }: Props) {
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={18}
className="shrink-0"
/>
{bot.name}
{workspace_id && bot.is_admin ? <AdminShield size={13} /> : null}
<Span className="min-w-0 truncate">{bot.name}</Span>
{workspace_id && bot.is_admin ? <AdminShield size={13} className="shrink-0" /> : null}
</Link>
);
}
@@ -10,7 +10,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
import TaskDetailModal from "@/src/components/general/task-detail-modal";
import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item";
@@ -31,15 +31,16 @@ type Props = {
/**
* The tasks this conversation works on: a bot's own list, or in a workspace
* the ones it has there plus the shared ones nobody has yet. Open it from the header menu; the list loads
* the ones it has there. Open it from the header menu; the list loads
* then, so the page's props stay small. A crew manager adds, moves, edits
* and deletes; a task opens to show its notes and files.
* and deletes; a card opens the task in a modal of its own with everything
* about it.
*/
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
const [adding, set_adding] = useState(false);
const [open_task, set_open_task] = useState<TaskDetail | null>(null);
const [open_task, set_open_task] = useState<TaskWithNames | null>(null);
const [busy, set_busy] = useState(false);
const [status, set_status] = useState<string>();
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
@@ -80,24 +81,27 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
replace({ task: res.singleRes });
};
const show = async ({ task }: { task: TaskWithNames }) => {
const remove = async ({ task }: { task: TaskWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
set_busy(true);
set_status(undefined);
/**
* The list already has the task; the detail endpoint serves its
* notes and files.
*/
const detail = await fetchApi<{}, APIResponseObject<TaskDetail>>(
`/api/admin/tasks/get-task?task_id=${task.id}`,
{ method: "GET" },
).catch(() => null);
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
"/api/admin/tasks/delete-task",
{ method: "POST", body: { task_id: Number(task.id) } },
);
if (!detail?.success || !detail.singleRes) {
set_status(detail?.msg || "Couldn't open the task.");
set_busy(false);
if (!res.success) {
set_status(res.msg || "That didn't work.");
return;
}
set_open_task(detail.singleRes);
setTasks((prev) => prev.filter((item) => item.id != task.id));
set_status("Task deleted.");
};
return (
@@ -136,72 +140,30 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
<Stack className="w-full gap-1! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
{open_task ? open_task.title || "Task" : title}
{title}
</H2>
{open_task ? (
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => set_open_task(null)}
className="px-2! text-[12px]"
>
Back
</Button>
) : can_manage ? (
{can_manage ? (
<Button
title="Add a task"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => {
set_adding(true);
set_open_task(null);
}}
onClick={() => set_adding(true)}
>
Add task
</Button>
) : null}
</Row>
{!open_task ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{workspace?.id
? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.`
: `What ${name} is working through, and what it's waiting on.`}
</Span>
) : null}
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{workspace?.id
? `What ${name} has in ${workspace.name || "this workspace"}. The workspace shows every bot's.`
: `What ${name} is working through, and what it's waiting on.`}
</Span>
</Stack>
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
{open_task ? (
<TaskDetailPanel
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_status(msg);
/**
* Handed to another bot, it's that bot's now.
*/
if (saved.bot_id && saved.bot_id != bot.id) {
setTasks((prev) => prev.filter((item) => item.id != saved.id));
set_open_task(null);
return;
}
set_open_task(saved);
replace({ task: saved });
}}
onDeleted={() => {
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
set_open_task(null);
set_status("Task deleted.");
}}
/>
) : adding ? (
{adding ? (
<TaskForm
bot_id={Number(bot.id)}
workspace_id={workspace?.id ? Number(workspace.id) : null}
@@ -218,8 +180,9 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
key={task.id}
task={task}
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onOpen={() => set_open_task(task)}
onStatusChange={(next) => move({ task, next })}
onDelete={() => remove({ task }).catch(() => {})}
/>
))
) : (
@@ -234,6 +197,31 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
{status}
</Span>
) : null}
<TaskDetailModal
task={open_task}
can_manage={can_manage}
onClose={() => set_open_task(null)}
onChanged={({ task: saved, msg }) => {
set_status(msg);
/**
* Handed to another bot, it's that bot's now.
*/
if (saved.bot_id && saved.bot_id != bot.id) {
setTasks((prev) => prev.filter((item) => item.id != saved.id));
set_open_task(null);
return;
}
replace({ task: saved });
}}
onDeleted={({ task_id }) => {
setTasks((prev) => prev.filter((item) => item.id != task_id));
set_open_task(null);
set_status("Task deleted.");
}}
/>
</Modal>
);
}
@@ -38,14 +38,15 @@ export default function ComputerRefChip({ computer_id }: Props) {
openComputer({ computer_id: computer.id || 0, mode: "browser" })
}
className={twMerge(
"inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap text-ink dark:text-ink",
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap text-ink dark:text-ink",
"[&_.twui-button-content-wrapper]:min-w-0",
current
? "bg-sunken hover:bg-sunken outline-0"
: "bg-transparent hover:bg-sunken",
)}
>
<Monitor size={14} aria-hidden className="shrink-0" />
{name}
<Span className="min-w-0 truncate">{name}</Span>
{current ? <Check size={14} aria-hidden className="shrink-0" /> : null}
</Button>
);
@@ -8,7 +8,7 @@ import Dropdown from "@moduletrace/twui/elements/Dropdown";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import { CpuAi, EllipsisVertical, Monitor } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import type { BotTextModel, BotWithLastMessage } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
@@ -27,6 +27,12 @@ type Props = {
*/
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
has_other_unread?: boolean;
/**
* The text model the bot runs on, kept by the conversation (which
* shows a notice while there's none).
*/
model?: BotTextModel | null;
onModelChange: (model: BotTextModel | null) => void;
};
/**
@@ -40,10 +46,11 @@ export default function ConversationHeader({
bot,
workspace,
has_other_unread,
model,
onModelChange,
}: Props) {
const { computers } = useContext(BotComputersContext);
const { pageProps } = useContext(AppContext);
const [model, setModel] = useState(pageProps?.bot_text_model);
/**
* The menu's toggle shows its change at once; a push (here or from
* another tab) brings the live bot's.
@@ -143,7 +150,7 @@ export default function ConversationHeader({
(h) => h.harness == "text",
)?.current;
setModel(
onModelChange(
current
? {
name: current.name,
@@ -22,7 +22,7 @@ type Props = {
*/
function CrewMessageLine({ message }: Props) {
return (
<Stack className="w-full gap-2.5! items-start break-words">
<Stack className="w-full gap-2.5! items-start wrap-anywhere [&>*]:max-w-full">
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
{linkComputerRefs({ text: message.text || "" })}
</Markdown>
@@ -1,4 +1,5 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
@@ -43,13 +44,16 @@ export default function HookProposalRefChip({ proposal_id }: Props) {
color="gray"
size="smaller"
title={`Review: ${title}`}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
className={twMerge(
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
"[&_.twui-button-content-wrapper]:min-w-0",
)}
>
<Webhook size={14} aria-hidden className="shrink-0" />
{`${title} · ${status}`}
<Span className="min-w-0 truncate">{`${title} · ${status}`}</Span>
</Button>
}
targetWrapperProps={{ className: "inline-flex align-middle" }}
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
@@ -13,8 +13,10 @@ import {
WorkspaceRefHrefPrefix,
} from "@/src/utils/link-computer-refs";
import { parseFolderRefHref } from "@/src/utils/folder-ref";
import { botQuestionRefId } from "@/src/utils/bot-question-ref";
import { graphicRefId } from "@/src/utils/graphic-ref";
import { chatroomRefId } from "@/src/utils/chatroom-ref";
import BotQuestionRefChip from "./bot-question-ref-chip";
import BotRefChip from "./bot-ref-chip";
import ChatroomRefChip from "./chatroom-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
@@ -36,7 +38,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
* folder it named a chip that opens it in the files browser, an MCP server,
* tool, skill or hook it proposed a chip that opens the confirm card, a
* secret it showed a masked chip, a secret it asked for a chip that opens
* the card it's typed into, a graphic it made the graphic itself, a
* the card it's typed into, questions it asked a chip that opens the card
* they're answered in, a graphic it made the graphic itself, a
* chatroom a chip that opens it;
* anything else opens in a new tab.
*/
@@ -104,6 +107,10 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
}
const question_id = botQuestionRefId({ href });
if (question_id) return <BotQuestionRefChip question_id={question_id} />;
const chatroom_id = chatroomRefId({ href });
if (chatroom_id) return <ChatroomRefChip chatroom_id={chatroom_id} />;
@@ -121,7 +128,7 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline underline-offset-2 hover:opacity-80 break-words"
className="text-ink underline underline-offset-2 hover:opacity-80 wrap-anywhere"
>
{children}
</Link>
@@ -26,7 +26,7 @@ export default function MarkdownList({ children, ordered, start, className }: Pr
start={start}
items={items}
className={twMerge(
`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`,
`self-stretch gap-1! pl-5 my-0 text-sm leading-5 text-ink [&>li]:max-w-full ${ordered ? "list-decimal" : "list-disc"}`,
className,
)}
/>
@@ -1,4 +1,5 @@
import { useMemo, type RefObject } from "react";
import { useMemo, type ReactNode, type RefObject } from "react";
import { twMerge } from "tailwind-merge";
import isAwaitingReply from "../(utils)/is-awaiting-reply";
import Stack from "@moduletrace/twui/layout/Stack";
import type {
@@ -37,6 +38,11 @@ type Props = {
* The conversation went on in another session: no reply is due here.
*/
moved_on?: boolean;
/**
* Shown after the messages, or in the middle of an empty thread, e.g.
* that the bot has no model to answer with.
*/
notice?: ReactNode;
/**
* Quotes text selected in a message in the composer.
*/
@@ -78,6 +84,7 @@ export default function MessageThread({
receiving,
has_task,
moved_on,
notice,
onQuote,
older,
}: Props) {
@@ -97,7 +104,10 @@ export default function MessageThread({
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
className={twMerge(
"w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6",
notice && !messages.length && "grow justify-center",
)}
>
{older?.has_more ? (
<OlderMessagesLoader
@@ -121,6 +131,7 @@ export default function MessageThread({
group={group}
/>
))}
{notice}
<BotThinkingRow
bot={bot}
progress={progress}
@@ -0,0 +1,66 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { ArrowRight, CpuAi } from "lucide-react";
import { SettingsModelsPath, SettingsSetupPath } from "@/src/dict/settings-pages-dict";
import { AppContext } from "@/src/pages/__root";
import IconTile from "@/src/components/general/icon-tile";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
type Props = {
bot_name: string;
};
/**
* Shown in the conversation while the bot has no text model to run on
* (nothing would answer a message). A super admin is sent to connect a
* provider, or to the guided setup; anyone else is told who can.
*/
export default function NoModelNotice({ bot_name }: Props) {
const { pageProps } = useContext(AppContext);
const can_set_up = isPassThroughUser({ user_types: pageProps?.user_types });
return (
<Stack
role="status"
className="w-full max-w-[440px] mx-auto gap-3! items-start rounded-default border border-solid border-hairline bg-surface px-5 py-4"
>
<Row className="gap-3! flex-nowrap items-center">
<IconTile Icon={CpuAi} />
<Span className="text-sm font-bold leading-5 text-ink">
{can_set_up ? "Connect a model provider" : "No model set up yet"}
</Span>
</Row>
<Span className="text-[13px] leading-5 text-ink-secondary">
{can_set_up
? `${bot_name} has no model to think with, so nothing would answer yet. Add a provider key (OpenRouter has free models) and pick what your bots run on.`
: `${bot_name} has no model to think with, so nothing would answer yet. The super admin connects one in Settings.`}
</Span>
{can_set_up ? (
<Row className="w-full gap-2! justify-end">
<Button
title="Open the guided setup"
type="button"
variant="outlined"
color="gray"
size="small"
href={SettingsSetupPath}
>
Guided setup
</Button>
<Button
title="Connect a provider"
type="button"
size="small"
href={SettingsModelsPath}
afterIcon={<ArrowRight size={14} aria-hidden />}
>
Connect a provider
</Button>
</Row>
) : null}
</Stack>
);
}
@@ -1,4 +1,5 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
@@ -47,13 +48,16 @@ export default function ProposalRefChip({ proposal_id }: Props) {
color="gray"
size="smaller"
title={`Review: ${title}`}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
className={twMerge(
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
"[&_.twui-button-content-wrapper]:min-w-0",
)}
>
<Icon size={14} aria-hidden className="shrink-0" />
{`${title} · ${status}`}
<Span className="min-w-0 truncate">{`${title} · ${status}`}</Span>
</Button>
}
targetWrapperProps={{ className: "inline-flex align-middle" }}
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
@@ -1,4 +1,5 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
@@ -46,13 +47,16 @@ export default function SecretRequestRefChip({ request_id }: Props) {
color="gray"
size="smaller"
title={view.status == "pending" ? `Enter ${view.key}` : `${view.key}: ${status}`}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
className={twMerge(
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
"[&_.twui-button-content-wrapper]:min-w-0",
)}
>
<KeyRound size={14} aria-hidden className="shrink-0" />
{`${view.key} · ${status}`}
<Span className="min-w-0 truncate">{`${view.key} · ${status}`}</Span>
</Button>
}
targetWrapperProps={{ className: "inline-flex align-middle" }}
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
@@ -1,4 +1,5 @@
import { useContext } from "react";
import { twMerge } from "tailwind-merge";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import { Hand } from "lucide-react";
@@ -34,10 +35,13 @@ export default function TakeOverChip({ computer_id, mode }: Props) {
size="smaller"
title={`Open ${name} (${mode_title}) and take over`}
onClick={() => openComputer({ computer_id, mode, take_over: true })}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
className={twMerge(
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
"[&_.twui-button-content-wrapper]:min-w-0",
)}
>
<Hand size={14} aria-hidden className="shrink-0" />
{`Take over ${name} · ${mode_title}`}
<Span className="min-w-0 truncate">{`Take over ${name} · ${mode_title}`}</Span>
</Button>
);
}
@@ -25,7 +25,7 @@ export default function UserBubble({ text }: { text: string }) {
{...{ [QuotableAttribute]: "" }}
className={twMerge(
"max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg",
"bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words",
"bg-ink text-on-ink px-4 py-3 text-sm leading-5 wrap-anywhere",
"conversation-user-bubble",
)}
>
@@ -14,7 +14,7 @@ export default function UserMarkdownLink(props: ComponentProps<"a">) {
href={props.href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 dark:text-blue-600 underline underline-offset-2 hover:opacity-80 break-words"
className="text-blue-400 dark:text-blue-600 underline underline-offset-2 hover:opacity-80 wrap-anywhere"
>
{props.children}
</Link>
@@ -24,10 +24,10 @@ export default function WorkspaceRefChip({ workspace_id }: Props) {
<Link
href={`/admin/workspaces/${workspace.id}`}
title={`Open ${workspace.name}`}
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
>
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
{workspace.name}
<Span className="min-w-0 truncate">{workspace.name}</Span>
</Link>
);
}
@@ -39,6 +39,7 @@ import useConversationHistory from "../(hooks)/use-conversation-history";
import mergeLoadedHistory from "../(utils)/merge-loaded-history";
import LoadingLine from "@/src/components/general/loading-line";
import SessionMovedBar from "../(partials)/session-moved-bar";
import NoModelNotice from "../(partials)/no-model-notice";
import syncSessionQuery from "../(utils)/sync-session-query";
import announceShownSession from "@/src/functions/frontend/announce-shown-session";
@@ -85,6 +86,11 @@ export default function Conversation() {
*/
const [moved_to_session_id, setMovedToSessionId] = useState<number | null>(null);
const [has_other_unread, setHasOtherUnread] = useState(false);
/**
* The text model the bot runs on here; none shows a notice in the
* thread. A save in the models modal updates it.
*/
const [text_model, setTextModel] = useState(pageProps?.bot_text_model || null);
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
null,
);
@@ -391,6 +397,8 @@ export default function Conversation() {
bot={live_bot}
workspace={workspace}
has_other_unread={has_other_unread}
model={text_model}
onModelChange={setTextModel}
/>
{history.loading ? (
<LoadingLine label="Loading the conversation" />
@@ -409,6 +417,11 @@ export default function Conversation() {
receiving={pending_messages.length > 0}
has_task={Boolean(progress.task_id)}
moved_on={Boolean(moved_to_session_id)}
notice={
!text_model && !history.loading ? (
<NoModelNotice bot_name={live_bot.name || "This bot"} />
) : null
}
older={older}
onQuote={({ text }) =>
composer.current?.insertQuote({ text })
@@ -0,0 +1,22 @@
import type { BotQuestionAnswer } from "@/src/types";
/**
* One question's answer while the user fills in the card: the options
* picked, whether "Other" is picked, and what's typed into it.
*/
export type BotQuestionDraft = {
choices: number[];
other_on: boolean;
other: string;
};
type Params = {
draft: BotQuestionDraft;
};
/**
* A question's draft as the answer sent: "Other" counts only while picked.
*/
export default function botQuestionDraftAnswer({ draft }: Params): BotQuestionAnswer {
return { choices: draft.choices, other: draft.other_on ? draft.other.trim() : "" };
}
@@ -32,7 +32,7 @@ type Props = {
*/
export default function SettingsListRow({ avatar, title, href, badge, title_icon, subtitle, children }: Props) {
const title_content = (
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
<Span className="block text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{title_icon ? <Span className="ml-1.5 inline-flex align-middle">{title_icon}</Span> : null}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
@@ -47,7 +47,7 @@ export default function SettingsListRow({ avatar, title, href, badge, title_icon
<Link
href={href}
title={title}
className="block min-w-0 border-0 leading-normal hover:opacity-100 hover:underline focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-surface rounded-default-sm"
className="block w-full min-w-0 border-0 leading-normal hover:opacity-100 hover:underline focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-surface rounded-default-sm"
>
{title_content}
</Link>
@@ -0,0 +1,45 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { HarnessOverviewPayload } from "@/src/types";
import postHarnessApi from "../(functions)/post-harness-api";
import type changedHarnessSettings from "../(utils)/changed-harness-settings";
/**
* The providers and harness settings a page edits (the Models settings
* and the setup), starting from the page's props and updated from the
* fresh data every save returns. `version` remounts the harness form when
* the data changes under it; `notice` is the last save's message, kept
* across that remount.
*/
export default function useModelsOverview() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState(pageProps?.harness_overview);
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
const update = (next: HarnessOverviewPayload) => {
setOverview(next);
setNotice(undefined);
setVersion((prev) => prev + 1);
};
const saveSettings = async ({
settings,
}: {
settings: ReturnType<typeof changedHarnessSettings>;
}) => {
const res = await postHarnessApi({
path: "save-settings",
body: { settings },
});
if (res.success && res.singleRes) {
update(res.singleRes);
setNotice(res.msg || "Saved.");
}
return res;
};
return { overview, version, notice, update, saveSettings };
}
@@ -1,30 +1,16 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import HarnessSettingsForm from "../(partials)/harness-settings-form";
import ProvidersGroup from "../(partials)/providers-group";
import postHarnessApi from "../(functions)/post-harness-api";
import useModelsOverview from "../(hooks)/use-models-overview";
/**
* Providers first (where models come from), then what each harness uses.
* Both update from the fresh data every save returns.
*/
export default function ModelsSettingsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState(pageProps?.harness_overview);
/**
* Remounts the harness form when the data changes under it.
*/
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
const { overview, version, notice, update, saveSettings } = useModelsOverview();
if (!overview) return null;
const update = (next: typeof overview) => {
setOverview(next);
setNotice(undefined);
setVersion((prev) => prev + 1);
};
return (
<>
<ProvidersGroup providers={overview.providers} onChanged={update} />
@@ -32,19 +18,7 @@ export default function ModelsSettingsSection() {
key={version}
harnesses={overview.harnesses}
notice={notice}
save={async ({ settings }) => {
const res = await postHarnessApi({
path: "save-settings",
body: { settings },
});
if (res.success && res.singleRes) {
update(res.singleRes);
setNotice(res.msg || "Saved.");
}
return res;
}}
save={saveSettings}
/>
</>
);
@@ -0,0 +1,41 @@
import { Bot, Monitor, SlidersHorizontal, Users, Variable } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import { SettingsComputersPath, SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import type { SetupLink } from "../(partials)/setup-link-row";
/**
* Where to go once the models are in: the rest is set up from the pages
* themselves.
*/
export const SetupNextLinks: SetupLink[] = [
{
title: "Meet the coordinator",
description: "Ask it to put a crew together, or just say hello.",
href: AdminHomePath,
Icon: Bot,
},
{
title: "Computers",
description: "The main computer starts by itself. Add more, or check it's running.",
href: SettingsComputersPath,
Icon: Monitor,
},
{
title: "Users",
description: "Add the people who'll work with your bots.",
href: "/admin/settings/users",
Icon: Users,
},
{
title: "Environment",
description: "Keys and settings your bots' tools need on their computers.",
href: "/admin/settings/environment",
Icon: Variable,
},
{
title: "General",
description: "The app's name and icon, run limits and how long things are kept.",
href: SettingsDefaultPath,
Icon: SlidersHorizontal,
},
];
@@ -0,0 +1,11 @@
/**
* The setup's steps, in order. Every one can be skipped.
*/
export const SetupSteps = [
{ key: "welcome", title: "Welcome" },
{ key: "provider", title: "Provider" },
{ key: "models", title: "Models" },
{ key: "ready", title: "Ready" },
] as const;
export type SetupStepKey = (typeof SetupSteps)[number]["key"];
@@ -0,0 +1,24 @@
import type { LucideIcon } from "lucide-react";
import IconTile from "@/src/components/general/icon-tile";
import SettingsListRow from "../../(partials)/settings-list-row";
export type SetupLink = {
title: string;
description: string;
href: string;
Icon: LucideIcon;
};
/**
* One place to go on from the setup.
*/
export default function SetupLinkRow({ title, description, href, Icon }: SetupLink) {
return (
<SettingsListRow
avatar={<IconTile Icon={Icon} />}
title={title}
href={href}
subtitle={description}
/>
);
}
@@ -0,0 +1,42 @@
import { twMerge } from "tailwind-merge";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
number: number;
current: boolean;
done: boolean;
onSelect: () => void;
};
/**
* One step in the setup's trail: its number and name. Any step can be
* opened, in any order.
*/
export default function SetupStepItem({ title, number, current, done, onSelect }: Props) {
return (
<Button
title={`Step ${number}: ${title}`}
type="button"
variant="ghost"
color="text"
aria-current={current ? "step" : undefined}
className={twMerge(
"gap-2! px-2! py-1! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink",
current && "text-ink dark:text-ink font-bold",
)}
onClick={onSelect}
>
<Span
className={twMerge(
"size-5 shrink-0 flex items-center justify-center rounded-full border border-solid border-hairline-strong text-[11px] leading-none",
(current || done) && "bg-ink text-on-ink border-ink",
)}
>
{number}
</Span>
<Span className="text-[13px] leading-5">{title}</Span>
</Button>
);
}
@@ -0,0 +1,72 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
type Props = {
is_first: boolean;
is_last: boolean;
onBack: () => void;
onNext: () => void;
};
/**
* Under each step: skip the whole setup, go back, or go on. The last step
* goes to the bots.
*/
export default function SetupStepNav({ is_first, is_last, onBack, onNext }: Props) {
return (
<Row className="w-full gap-2! items-center justify-between py-6">
{is_last ? (
<Row />
) : (
<Button
title="Skip the setup"
type="button"
variant="ghost"
color="gray"
size="small"
href={AdminHomePath}
>
Skip setup
</Button>
)}
<Row className="gap-2! ml-auto">
{is_first ? null : (
<Button
title="Back"
type="button"
variant="outlined"
color="gray"
size="small"
beforeIcon={<ArrowLeft size={14} aria-hidden />}
onClick={onBack}
>
Back
</Button>
)}
{is_last ? (
<Button
title="Go to your bots"
type="button"
size="small"
href={AdminHomePath}
afterIcon={<ArrowRight size={14} aria-hidden />}
>
Start chatting
</Button>
) : (
<Button
title="Next step"
type="button"
size="small"
afterIcon={<ArrowRight size={14} aria-hidden />}
onClick={onNext}
>
Next
</Button>
)}
</Row>
</Row>
);
}
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import { SetupSteps } from "../(data)/setup-steps";
import SetupStepItem from "./setup-step-item";
type Props = {
index: number;
onSelect: (index: number) => void;
};
/**
* Where the setup is: every step, the current one marked.
*/
export default function SetupStepsTrail({ index, onSelect }: Props) {
return (
<Row
aria-label="Setup steps"
className="w-full gap-1! md:gap-2! pt-6 pb-2 border-0 border-b border-solid border-hairline"
>
{SetupSteps.map((step, i) => (
<SetupStepItem
key={step.key}
title={step.title}
number={i + 1}
current={i == index}
done={i < index}
onSelect={() => onSelect(i)}
/>
))}
</Row>
);
}
@@ -0,0 +1,30 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { CircleCheck, CircleAlert } from "lucide-react";
import type { HarnessOverview } from "@/src/types";
type Props = {
text?: HarnessOverview;
};
/**
* Whether the bots have a model to think with yet, and which.
*/
export default function SetupTextModelStatus({ text }: Props) {
const current = text?.current;
return (
<Row className="w-full gap-2! flex-nowrap items-start rounded-default border border-solid border-hairline bg-surface px-4 py-3">
{current ? (
<CircleCheck size={16} aria-hidden className="shrink-0 mt-0.5" />
) : (
<CircleAlert size={16} aria-hidden className="shrink-0 mt-0.5" />
)}
<Span className="text-[13px] leading-5 text-ink">
{current
? `Your bots think with ${current.name || current.slug} on ${current.provider_name}.`
: "Your bots have no model to think with yet. Add a key to a provider (OpenRouter has free models) or add another provider."}
</Span>
</Row>
);
}
@@ -0,0 +1,109 @@
import { useContext, useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import { SiteData } from "@/src/data/site-data";
import { AppContext } from "@/src/pages/__root";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import InstallNameForm from "../../general/(partials)/install-name-form";
import HarnessSettingsForm from "../../models/(partials)/harness-settings-form";
import ProvidersGroup from "../../models/(partials)/providers-group";
import useModelsOverview from "../../models/(hooks)/use-models-overview";
import { SetupSteps } from "../(data)/setup-steps";
import { SetupNextLinks } from "../(data)/setup-next-links";
import SetupLinkRow from "../(partials)/setup-link-row";
import SetupStepNav from "../(partials)/setup-step-nav";
import SetupStepsTrail from "../(partials)/setup-steps-trail";
import SetupTextModelStatus from "../(partials)/setup-text-model-status";
/**
* The guided setup a new install opens on after the super admin signs
* up, and that stays under Settings to come back to: name the app,
* connect a provider, pick the models, then where to go next. Each step
* saves on its own and any can be skipped; the provider and model steps
* share their data, so a key added shows in the models at once.
*/
export default function SetupSection() {
const { pageProps } = useContext(AppContext);
const { overview, version, notice, update, saveSettings } = useModelsOverview();
const [index, setIndex] = useState(0);
if (!overview) return null;
const step = SetupSteps[index]!.key;
const text = overview.harnesses.find((harness) => harness.harness == "text");
const go = (next: number) => {
setIndex(Math.max(0, Math.min(SetupSteps.length - 1, next)));
};
return (
<>
<SetupStepsTrail index={index} onSelect={go} />
{step == "welcome" ? (
<>
<SettingsGroup title={`Welcome to ${SiteData["SiteName"]}`}>
<Span className="text-sm leading-6 text-ink">
{`A few steps get your crew working: connect where models come from, then pick what your bots think, see, hear and speak with. Every step saves on its own, and you can skip any of them. This setup stays under Settings to come back to.`}
</Span>
</SettingsGroup>
<SettingsGroup
title="App name"
description="The name this install goes by when it's added to a home screen or app list. Optional."
>
<InstallNameForm install_name={pageProps?.install_name} />
</SettingsGroup>
</>
) : null}
{step == "provider" ? (
<>
<SettingsGroup
title="Connect a provider"
description="Your bots need at least one model provider. OpenRouter is already added: give it a key and its free models work right away."
>
<SetupTextModelStatus text={text} />
</SettingsGroup>
<ProvidersGroup providers={overview.providers} onChanged={update} />
</>
) : null}
{step == "models" ? (
<>
<SettingsGroup
title="Pick models"
description="Text picks a free model by itself until you choose one. Transcription, Speech and Embeddings (knowledgebase search) need a model picked before they work."
>
<SetupTextModelStatus text={text} />
</SettingsGroup>
<HarnessSettingsForm
key={version}
harnesses={overview.harnesses}
notice={notice}
save={saveSettings}
/>
</>
) : null}
{step == "ready" ? (
<>
<SettingsGroup title="You're set">
<SetupTextModelStatus text={text} />
</SettingsGroup>
<SettingsGroup
title="Next"
description="Everything else is set up from its own page, whenever you need it."
>
<SettingsList>
{SetupNextLinks.map((link) => (
<SetupLinkRow key={link.href} {...link} />
))}
</SettingsList>
</SettingsGroup>
</>
) : null}
<SetupStepNav
is_first={index == 0}
is_last={index == SetupSteps.length - 1}
onBack={() => go(index - 1)}
onNext={() => go(index + 1)}
/>
</>
);
}
@@ -0,0 +1,10 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { server as modelsServer } from "../models/index.server";
/**
* Super admins only, with the same data as the Models page: the setup's
* provider and model steps edit it.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) =>
await modelsServer(params);
+17
View File
@@ -0,0 +1,17 @@
import { SiteData } from "@/src/data/site-data";
import SetupSection from "./(sections)/setup-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsSetupPage() {
return (
<SettingsShell
title="Setup"
description={`Get ${SiteData["SiteName"]} ready to work, a step at a time.`}
>
<SetupSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Setup" });
@@ -1,5 +1,6 @@
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import { AppData } from "@/src/data/app-data";
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";
@@ -13,14 +14,18 @@ type Props = {
};
/**
* One recent conversation in the workspace: the bot, the session's title,
* a preview of its latest message and when it last moved.
* One recent conversation in the workspace: the bot, the session's title
* (what it opened with until it's named), a preview of its latest message
* and when it last moved.
*/
export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
const at = item.last_activity_at || item.created_at;
const preview = item.last_message_text
? messagePreview({ text: item.last_message_text })
: "";
const opening = item.first_message_text
? messagePreview({ text: item.first_message_text }).slice(0, AppData.BotSessionTitleMaxChars)
: "";
return (
<SettingsListRow
@@ -31,7 +36,7 @@ export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
face={(item.bot_face || "idle") as BotFace}
/>
}
title={item.title || "Untitled conversation"}
title={item.title || opening || "Untitled conversation"}
href={`/admin/workspaces/${workspace_id}/bots/${item.bot_id}`}
badge={item.unread ? "New" : undefined}
subtitle={`${item.bot_name || "A bot"}${preview ? ` · ${preview}` : ""}`}
@@ -6,7 +6,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import type { TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
import TaskDetailModal from "@/src/components/general/task-detail-modal";
import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
@@ -22,20 +22,25 @@ type Props = {
* Its tasks, open first.
*/
tasks: TaskWithNames[];
/**
* The bots working there, to pick who has a new task.
*/
bots: { id?: number | string | null; name?: string | null }[];
};
/**
* Every task in a workspace, whichever bot has it (or shared, nobody's
* yet). Each bot there sees its own and the shared ones. Add and change one by hand here; the bots
* Every task in a workspace, whichever bot has it: every task belongs to
* one bot, which sees only its own (the workspace's admin bot sees all).
* Add and change one by hand here; the bots
* keep it up to date themselves as they work.
*/
export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
export default function WorkspaceTasksGroup({ workspace_id, tasks, bots }: Props) {
const { pageProps } = useContext(AppContext);
const [items, setItems] = useState(tasks);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>();
const [adding, setAdding] = useState(false);
const [open_task, setOpenTask] = useState<TaskDetail | null>(null);
const [open_task, setOpenTask] = useState<TaskWithNames | null>(null);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const replace = ({ task }: { task: TaskWithNames }) =>
@@ -62,101 +67,88 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
replace({ task: res.singleRes });
};
const show = async ({ task }: { task: TaskWithNames }) => {
const remove = async ({ task }: { task: TaskWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
setBusy(true);
setStatus(undefined);
const res = await fetchApi<{}, APIResponseObject<TaskDetail>>(
`/api/admin/tasks/get-task?task_id=${task.id}`,
{ method: "GET" },
).catch(() => null);
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
"/api/admin/tasks/delete-task",
{ method: "POST", body: { task_id: Number(task.id) } },
);
if (!res?.success || !res.singleRes) {
setStatus({ error: true, msg: res?.msg || "Couldn't open the task." });
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
setOpenTask(res.singleRes);
setItems((prev) => prev.filter((item) => item.id != task.id));
setStatus({ success: true, msg: "Task deleted." });
};
return (
<SettingsGroup
id="tasks"
title="Tasks"
description="Every task in this workspace, whichever bot has it. A bot sees its own here and the shared ones nobody has taken up yet."
description="Every task in this workspace, whichever bot has it. Each bot sees only its own; the workspace's admin bot and the coordinator see them all."
>
<Stack className="w-full gap-2! items-stretch">
{open_task ? (
<Stack className="w-full gap-3! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="grow min-w-0 text-sm font-bold leading-5 text-ink truncate">
{open_task.title || "Task"}
</Span>
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setOpenTask(null)}
className="px-2! text-[12px]"
>
Back
</Button>
</Row>
<TaskDetailPanel
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
setOpenTask(saved);
replace({ task: saved });
setStatus(msg ? { success: true, msg } : undefined);
}}
onDeleted={() => {
setItems((prev) => prev.filter((item) => item.id != open_task.id));
setOpenTask(null);
setStatus({ success: true, msg: "Task deleted." });
}}
{can_manage ? (
<Row className="justify-end">
<Button
title={bots.length ? "Add a task" : "Add a bot to this workspace first"}
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => setAdding(true)}
disabled={!bots.length}
>
Add task
</Button>
</Row>
) : null}
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
{items.map((task) => (
<TaskListItem
key={task.id}
task={task}
busy={busy}
onOpen={() => setOpenTask(task)}
onStatusChange={(next) => move({ task, next })}
onDelete={() => remove({ task }).catch(() => {})}
/>
</Stack>
) : (
<>
{can_manage ? (
<Row className="justify-end">
<Button
title="Add a task"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => setAdding(true)}
>
Add task
</Button>
</Row>
) : null}
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
{items.map((task) => (
<TaskListItem
key={task.id}
task={task}
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
/>
))}
</SettingsList>
</>
)}
))}
</SettingsList>
<SettingsStatus status={status} />
</Stack>
<TaskDetailModal
task={open_task}
can_manage={can_manage}
onClose={() => setOpenTask(null)}
onChanged={({ task, msg }) => {
replace({ task });
setStatus(msg ? { success: true, msg } : undefined);
}}
onDeleted={({ task_id }) => {
setItems((prev) => prev.filter((item) => item.id != task_id));
setOpenTask(null);
setStatus({ success: true, msg: "Task deleted." });
}}
/>
<AddModalShell
open={adding}
setOpen={setAdding}
title="Add a task"
description="It's added shared: whichever bot here takes it up has it. Hand it over to give it to one."
description="Pick the bot that has it. Hand it over later to give it to another."
>
<TaskForm
workspace_id={workspace_id}
bots={bots}
onCancel={() => setAdding(false)}
onSaved={(task, msg) => {
replace({ task });
@@ -42,6 +42,7 @@ export default function WorkspaceDashboard() {
<WorkspaceTasksGroup
workspace_id={workspace_id}
tasks={pageProps?.workspace_tasks || []}
bots={pageProps?.workspace_bots || []}
/>
<WorkspaceActivityGroup
workspace_id={workspace_id}
@@ -0,0 +1,40 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import answerBotQuestion from "@/src/functions/backend/db/bot-questions/answer-bot-question";
import grabBotQuestionView from "@/src/functions/backend/db/bot-questions/grab-bot-question-view";
import { AnswerBotQuestionSchema } from "@/src/schemas/bot-question-schema";
import type { BotQuestionView } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* The session user answered questions a bot asked them in its card.
* Answers with the questions as they now stand.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotQuestionView>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { question_id, answers } = parseWithSchema({ schema: AnswerBotQuestionSchema, data: body });
await answerBotQuestion({ question_id, user_id: user.id, answers });
return { success: true, singleRes: await grabBotQuestionView({ question_id, user_id: user.id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,32 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotQuestionView from "@/src/functions/backend/db/bot-questions/grab-bot-question-view";
import { BotQuestionIdSchema } from "@/src/schemas/bot-question-schema";
import type { BotQuestionView } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* `GET ?question_id=`: questions a bot asked the session user, as their
* chip and card show them. Nobody else reads them.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotQuestionView>> = async ({ req, query }) => {
try {
if (req.method != "GET") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { question_id } = parseWithSchema({ schema: BotQuestionIdSchema, data: query });
return { success: true, singleRes: await grabBotQuestionView({ question_id, user_id: user.id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,39 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotQuestionView from "@/src/functions/backend/db/bot-questions/grab-bot-question-view";
import skipBotQuestion from "@/src/functions/backend/db/bot-questions/skip-bot-question";
import { BotQuestionIdSchema } from "@/src/schemas/bot-question-schema";
import type { BotQuestionView } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* The session user skipped questions a bot asked them: nothing answered.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotQuestionView>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { question_id } = parseWithSchema({ schema: BotQuestionIdSchema, data: body });
await skipBotQuestion({ question_id, user_id: user.id });
return { success: true, singleRes: await grabBotQuestionView({ question_id, user_id: user.id }) };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+2 -3
View File
@@ -6,8 +6,8 @@ import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bune
/**
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's
* (`?workspace_id=`), one bot's there with the unassigned ones
* (`?workspace_id=&bot_id=`), or every task they reach. `?status=` narrows to one
* (`?workspace_id=`), one bot's there (`?workspace_id=&bot_id=`), or every
* task they reach. `?status=` narrows to one
* status, `?open=1` to the open ones.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
@@ -33,7 +33,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> =
payload: await grabTasks({
user_id: user.id,
only_bot_id: bot_id,
with_unassigned: Boolean(bot_id && workspace_id),
only_workspace_id: workspace_id,
own_list: Boolean(bot_id && !workspace_id),
statuses: status ? [status] : undefined,
+2 -2
View File
@@ -11,8 +11,8 @@ import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Adds a task to a bot's list (`bot_id`), a workspace (`workspace_id`,
* shared) or a bot in a workspace (both), or changes one (`task_id`). Returns the task with its
* Adds a task to a bot's list (`bot_id`) or a bot's in a workspace (with
* `workspace_id`), or changes one (`task_id`). Returns the task with its
* notes and files.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {

Some files were not shown because too many files have changed in this diff Show More