Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e8e8de976 | ||
|
|
e75ab28041 | ||
|
|
4261535741 | ||
|
|
0166f31cfc | ||
|
|
caa9ad87e4 | ||
|
|
30a4df183c | ||
|
|
ee687e1d31 | ||
|
|
024d847b9a |
@@ -194,7 +194,7 @@ Every folder on the data volume, browsable read-only: a bot's own, a workspace's
|
||||
- **Providers** (`HarnessProviderKinds` in `src/dict/harness-provider-dict.ts`): OpenRouter (default, created by `ensureDefaultHarnessProvider`), OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama (local, its models count as free), any OpenAI-compatible server. Rows in `harness_providers` (a private table: the key is encrypted with `src/utils/crypt.ts`, never sent to the client; falls back to the preset's env, e.g. `OPENROUTER_API_KEY`, via `grabEnv`). One adapter today, `src/harness/providers/openai-compatible/` (`chat`, `transcribe`, `speak`, `generateImage`, `embed` (`POST /embeddings`, batched), `listModels`), chosen in `grab-harness-adapter.ts`; a provider needing a native API gets its own adapter there (`HarnessAdapter` in `src/harness/types.ts`).
|
||||
- **Catalog:** `harness_models` per provider (modalities, tools support, voices, Artificial Analysis scores, `free`, `available`). The `sync-harness-models` cron (hourly, on start) refreshes every enabled provider (`syncAllHarnessModels`); unlisted models are kept but marked unavailable. OpenRouter lists every modality with capabilities; other providers list ids, so capabilities are guessed (`guess-model-modalities`). Free on OpenRouter means `:free` or `openrouter/free`, never zero token prices.
|
||||
- **Automatic picks** (`rank-auto-models`): only for the `automatic` harnesses in `Harnesses` (text, vision, image); transcription and speech have none and are not available until a model is picked (the model select reads "Not available", no fallback checkbox). Free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first.
|
||||
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts` (returns the provider's audio as a stream, falling back to the next model only until one answers), `image/generate-image.ts`, `embeddings/embed-text.ts` (batches, then `fit-vector-dimensions.ts` pads or cuts each vector to `AppData.EmbeddingDimensions` and normalizes it, since a provider's width varies). A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
|
||||
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `max_steps`, `AppData.HarnessMaxToolSteps` by default and the install's own saved limit for a task run, `grabHarnessMaxToolSteps` in `app-settings/grab-run-limits.ts` reading the `harness_max_tool_steps` app setting, and `grabReplyMaxToolSteps`/`bot_reply_max_tool_steps` for a chat reply in place of `AppData.BotReplyMaxToolSteps`; `src/utils/read-step-limit.ts` falls back to the code default for anything missing, below 1 or past `AppData.BotStepLimitMax`; trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts` (returns the provider's audio as a stream, falling back to the next model only until one answers), `image/generate-image.ts`, `embeddings/embed-text.ts` (batches, then `fit-vector-dimensions.ts` pads or cuts each vector to `AppData.EmbeddingDimensions` and normalizes it, since a provider's width varies). A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
|
||||
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
|
||||
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/storage/`: `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file`, `generate_image`, `make_pdf` (`grabBotFileTools`, every run, see Bot files and folders). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; a task handed over in the coordinator's own chat runs in the worker's own chat unless it names a `workspace_id`, which puts it in that workspace: the worker's workspace instructions and memory, the workspace's context, files and computers apply, and it needs membership, added first via `addBotToWorkspace` when missing; handed over from inside a workspace it always runs there and a different `workspace_id` is refused; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
|
||||
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
|
||||
@@ -203,7 +203,7 @@ Every folder on the data volume, browsable read-only: a bot's own, a workspace's
|
||||
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
|
||||
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
|
||||
- **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use.
|
||||
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools, the file tools and the job's computer tools, closing the MCP connection after. Both add `BotFilesNote` (where a bot's files live, that they persist, and to search before saying it doesn't know) and collect `attachments` for files the bot wrote with `attach`, which `completeBotJob` links to the result message. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
|
||||
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `grabReplyMaxToolSteps()` steps (the `bot_reply_max_tool_steps` app setting, else `AppData.BotReplyMaxToolSteps`), and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools, the file tools and the job's computer tools, closing the MCP connection after. Both add `BotFilesNote` (where a bot's files live, that they persist, and to search before saying it doesn't know) and collect `attachments` for files the bot wrote with `attach`, which `completeBotJob` links to the result message. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
|
||||
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
|
||||
|
||||
### Token usage
|
||||
@@ -230,7 +230,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
|
||||
- Routines, Files, Usage, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings. A page carries `super_admin_only` or a `can_view` rule (Usage is `canManageCrew`), and `canSeeRailItem` (`src/utils/rbac/can-see-rail-item.ts`, tested) decides both the popover and the aside, so a page is never offered in one and hidden in the other
|
||||
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
|
||||
- `/admin/settings/database` - super admin only (listed in `SettingsPages`), a read-only browser of the install's SQLite database, no writes anywhere in it. `grabDatabaseTables` reads the catalog once (every table and its columns, SQLite's own `sqlite_*` tables and sqlite-vec's `bot_file_vectors_*` shadow tables left out) and adds each table's `COUNT(*)` and its size from `dbstat` (index pages folded back onto their table). `grabTableRecords` then returns one page (`AppData.DatabaseBrowserPageSize`, 50) of the open table's records newest key first, every value already cut in SQL at `AppData.DatabaseBrowserCellMaxChars` (400) so a table of long texts never ships a megabyte of JSON, and a `truncated` flag per cell; the search matches over the concatenation of every column's text (`buildSearchClause`: every word, or the whole phrase), which is what also works on a virtual table, where `MATCH` and `LIKE` over a vector blob do not. A record's key is its single primary key column, else its rowid, else nothing (a virtual table with no primary key reads as plain cells); clicking a cell's row opens `grabTableRecord` for that key, which returns the record whole up to `AppData.DatabaseBrowserValueMaxChars` (20000), the answer to a cell cut at three lines. `clampPage` puts a page past the end on the last one. The page's state (table, search, mode, page) lives in the address through `databaseHref`, so a table or a search can be linked to; the open table's first page comes with the page props and anything else loads through the API. Helpers in `src/functions/backend/database/` (`build-table-catalog-sql`, `build-table-sizes-sql`, `build-records-sql`, `build-search-clause`, `resolve-table-key`, `build-record-row`, `quote-sql-identifier`), API `api/admin/database/records` and `record` (GET only, `grabSuperAdminUser`), zod in `src/schemas/database-schema.ts`, page under `src/pages/admin/settings/database/`
|
||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column that opens with the breadcrumb trail, `src/components/general/admin-breadcrumbs.tsx`: twui's `Breadcrumbs` reading `Home`, the sections and the page off `pageProps.url.pathname`, the last crumb named by the page's own `title` so a record reads `Scout` rather than `3` and `/admin/logs` reads `Activity`; `hasBreadcrumbTrail` drops it only on the crew of bots, which is home, and nothing outside `/admin` gets one). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||
|
||||
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
||||
|
||||
@@ -282,6 +282,7 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
|
||||
- Shared: `Logo`, `LogoIcon`, `InnerPageHeroSection`, `AdminHero`, `LoginShell` (auth pages/forms), `TaglineSection`
|
||||
- **UNCHANGEABLE RULE - twui components always win over raw JSX elements.** Use twui components (`H1`-`H5`, `P`, `Span`, `List`, `Img`, `Row`, `Stack`, `Container`, `Section`, `Card`, `Button`, `Tag`, `Link`, etc.) before basic JSX elements (`<h1>`-`<h6>`, `<p>`, `<span>`, `<ul>`, `<li>`, `<img>`, `<a>`, `<button>`, `<div>`-as-card, etc.). Never introduce or reintroduce a raw JSX element where a twui equivalent exists. Override twui defaults via `className` rather than reaching for the raw element.
|
||||
- **Never create new CSS classes, CSS files, or inline `style={{...}}` hacks.** Style exclusively by composing existing component props/classNames, existing twui classNames (`twui-*`), semantic theme-token utilities (`bg-dark`, `bg-light`, `text-dark`, `text-primary`, `bg-primary`, `text-accent`, `bg-accent`, `text-secondary`, `border-slate-200`, etc.), and utility classes already used elsewhere in this codebase. Build up from what exists; extend component code only when the existing API cannot express the need.
|
||||
- **Chunk Tailwind classes with `tailwind-merge`, never one long line.** A `className` is `twMerge(...)` (`import { twMerge } from "tailwind-merge"`) with one chunk per claim: base layout and spacing first, then one string per conditional, and a caller's override last, so a later chunk always beats an earlier one instead of depending on source order in the CSS. Never concatenate conditional classes with a template literal or a bare `+`, and never leave a single 20-class string where the parts mean different things: `className={twMerge("w-full shrink-0 flex-col gap-0! items-stretch rounded-default py-1", selected && "bg-ink/5")}`.
|
||||
- **Theming:** colors, radii, and fonts change only in `src/styles/main.css`: Cobble tokens as CSS vars on `:root` (light) and `.dark`, exposed in the `@theme inline` block as `paper`, `surface`, `sunken`, `ink`, `ink-secondary`, `ink-disabled`, `hairline`, `hairline-strong`, `on-ink`, `hue-<name>`, plus `shadow-popover` and `--m-fast/base/slow` motion vars (zeroed under reduced motion). Font is Lato (stand-in). Never hardcode hex colors, radii, or font stacks inside components. Hues are bot identity only, never state, and the `--color-hue-*` tokens remain for the fixed palettes that use them (the files browser's volume icons, `BotHues`' own hexes).
|
||||
- **The one exception:** a bot's colour is user data, not a theme value, so it reaches `BotStone` (`src/components/general/bot-stone.tsx`) and `UnreadDot` as an SVG `fill` attribute - an arbitrary hex has no token to point at. Nothing else in the app may take a colour that way, and `resolveBotColor`/`normalizeBotColor` (`src/utils/bot-color.ts`) are the only place a colour string is validated.
|
||||
- **Branding:** `docs/readme/hero-light.png`, `hero-dark.png`, `workspace-light.png` and `workspace-dark.png` are screenshots of the current app (chat and workspace dashboard, 1440px at 2x, downscaled to 1600px wide), committed and picked per theme by GitHub's `#gh-light-mode-only` / `#gh-dark-mode-only` fragment. They are not generated: when the UI changes, retake them from a seeded dev instance (`bun run seed`, `bun run seed:bots`) and keep the same names, so the README never shows an older dashboard. Never use `assets/handoff/assets/screens/*.png` for anything: that design is outdated. `public/logos/logo.svg` is the single source of truth for the logo mark; `logo-icon.tsx` renders it inline (via `BotStone`) and `logo.tsx` renders the stone beside the vector `Wordmark`. Whenever the logo changes, run `bun run gen:favicon` to regenerate `public/favicon.ico`, `public/favicon.png`, `public/apple-touch-icon.png`, and `public/logos/logo.png` (the last is used in emails). Font + favicon links live in `__root.tsx` `Head()`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cobble",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -2,15 +2,16 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import {
|
||||
ActionCheckNote,
|
||||
BotFilesNote,
|
||||
ChatReplyNote,
|
||||
botFilesNote,
|
||||
chatReplyNote,
|
||||
ComputerFilesNote,
|
||||
CoordinatorReplyNote,
|
||||
coordinatorReplyNote,
|
||||
CoordinatorSoloNote,
|
||||
TasksNote,
|
||||
TaskReminderNote,
|
||||
WorkspaceLearningNote,
|
||||
} from "@/src/data/harness-prompts";
|
||||
import { grabReplyMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||
@@ -137,7 +138,7 @@ export default async function runBotReply({
|
||||
bot.kind != "coordinator"
|
||||
? ""
|
||||
: tools.some((tool) => tool.name == "delegate_task")
|
||||
? CoordinatorReplyNote
|
||||
? coordinatorReplyNote({ bot_id: bot.id })
|
||||
: CoordinatorSoloNote;
|
||||
|
||||
try {
|
||||
@@ -145,8 +146,8 @@ export default async function runBotReply({
|
||||
system: [
|
||||
prompt,
|
||||
carry_over,
|
||||
ChatReplyNote,
|
||||
BotFilesNote,
|
||||
chatReplyNote({ bot_id: bot.id }),
|
||||
botFilesNote({ bot_id: bot.id }),
|
||||
ComputerFilesNote({ computer_id: computers[0]?.id || 0, can_import: false }),
|
||||
TasksNote,
|
||||
TaskReminderNote,
|
||||
@@ -173,7 +174,7 @@ export default async function runBotReply({
|
||||
}),
|
||||
}),
|
||||
tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools],
|
||||
max_steps: AppData.BotReplyMaxToolSteps,
|
||||
max_steps: await grabReplyMaxToolSteps(),
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
signal,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import {
|
||||
ActionCheckNote,
|
||||
BotFilesNote,
|
||||
botFilesNote,
|
||||
ComputerFilesNote,
|
||||
ScheduledRunNote,
|
||||
TasksNote,
|
||||
TaskRunNote,
|
||||
WorkspaceLearningNote,
|
||||
} from "@/src/data/harness-prompts";
|
||||
import { grabHarnessMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
@@ -190,7 +191,7 @@ export default async function runBotTask({
|
||||
carry_over,
|
||||
TaskRunNote,
|
||||
scheduled ? ScheduledRunNote : "",
|
||||
BotFilesNote,
|
||||
botFilesNote({ bot_id: bot.id }),
|
||||
ComputerFilesNote({ computer_id: job.computer_id || 0 }),
|
||||
TasksNote,
|
||||
ActionCheckNote,
|
||||
@@ -227,6 +228,7 @@ export default async function runBotTask({
|
||||
},
|
||||
],
|
||||
tools: [...tools, ...extensions.tools, ...skills.tools],
|
||||
max_steps: await grabHarnessMaxToolSteps(),
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import hasBreadcrumbTrail from "@/src/utils/has-breadcrumb-trail";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The address the page is at, e.g. `/admin/settings/bots/3`.
|
||||
*/
|
||||
pathname?: string | null;
|
||||
/**
|
||||
* The page's own name, for the last crumb: what the address can't say,
|
||||
* since its last segment is an id (`Scout`, not `3`) or a slug that
|
||||
* differs from the name the page shows (`MCPs`, not `Mcps`).
|
||||
*/
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The trail above a page's content: `Home`, then every section the address
|
||||
* passes through, then the page itself, named by `title`.
|
||||
*
|
||||
* It is `twui`'s `Breadcrumbs`, which reads the trail off the path, so the
|
||||
* sections come from the address and only the page's own name is given
|
||||
* here; `hasBreadcrumbTrail` says which pages get one at all.
|
||||
*/
|
||||
export default function AdminBreadcrumbs({ pathname, title }: Props) {
|
||||
if (!hasBreadcrumbTrail({ pathname })) return null;
|
||||
|
||||
return (
|
||||
<Stack className={twMerge("w-full shrink-0 pt-5 pb-1")}>
|
||||
<Breadcrumbs
|
||||
pageUrl={pathname || AdminHomePath}
|
||||
currentTitle={title}
|
||||
excludeRegexMatch={/^admin$/}
|
||||
linkProps={{
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||
}}
|
||||
currentLinkProps={{
|
||||
title,
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||
}}
|
||||
divider={
|
||||
<ChevronRight
|
||||
size={12}
|
||||
aria-hidden
|
||||
className="shrink-0 text-ink-secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -246,7 +246,10 @@ export const AppData = {
|
||||
HarnessCooldownMs: 60 * 1000,
|
||||
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
|
||||
/**
|
||||
* Model turns in one text run before it must stop and report.
|
||||
* Model turns in one text run before it must stop and report. The
|
||||
* install may save its own limit (the `harness_max_tool_steps` setting,
|
||||
* Settings > General), which wins over this one; a task run reads it
|
||||
* through `grabHarnessMaxToolSteps`.
|
||||
*/
|
||||
HarnessMaxToolSteps: 200,
|
||||
/**
|
||||
@@ -389,8 +392,17 @@ export const AppData = {
|
||||
WorkspaceActivityItems: 6,
|
||||
/**
|
||||
* Tool turns a chat reply may take. Long work goes to a task instead.
|
||||
* The install may save its own limit (the `bot_reply_max_tool_steps`
|
||||
* setting, Settings > General), which wins over this one; a reply
|
||||
* reads it through `grabReplyMaxToolSteps`.
|
||||
*/
|
||||
BotReplyMaxToolSteps: 8,
|
||||
/**
|
||||
* The most steps a saved run limit may ask for, for a task run or a
|
||||
* chat reply. A typo in the settings page can't turn one run into an
|
||||
* hour of tool calls, and a huge limit still ends with a report.
|
||||
*/
|
||||
BotStepLimitMax: 1000,
|
||||
/**
|
||||
* Chat attachments: per file, per message, and how much of a
|
||||
* document's text a reply reads.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
botFilesNote,
|
||||
chatReplyNote,
|
||||
ComputerFilesNote,
|
||||
coordinatorReplyNote,
|
||||
TasksNote,
|
||||
} from "./harness-prompts";
|
||||
|
||||
/**
|
||||
* Every note a bot reads names it by its own id, so a bot copying an
|
||||
* example never points at another bot's folder, conversation or task.
|
||||
*/
|
||||
const bot_ids = [1, 5, 42] as const;
|
||||
|
||||
/**
|
||||
* The bot ids a note's own-folder examples point at, in order.
|
||||
*/
|
||||
function folderOwners(note: string) {
|
||||
return [...note.matchAll(/\[folder:bot:(\d+):/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
describe("harness prompts", () => {
|
||||
for (const bot_id of bot_ids) {
|
||||
test(`chat reply note names bot ${bot_id}'s own folder`, () => {
|
||||
const owners = folderOwners(chatReplyNote({ bot_id }));
|
||||
|
||||
expect(owners).toEqual([String(bot_id)]);
|
||||
});
|
||||
|
||||
test(`files note names bot ${bot_id}'s own folder`, () => {
|
||||
const owners = folderOwners(botFilesNote({ bot_id }));
|
||||
|
||||
expect(owners).toEqual([String(bot_id)]);
|
||||
});
|
||||
|
||||
test(`coordinator note names itself as bot ${bot_id}`, () => {
|
||||
const note = coordinatorReplyNote({ bot_id });
|
||||
|
||||
expect(note).toContain(`[bot:${bot_id}]`);
|
||||
expect(note).not.toContain("[bot:3]");
|
||||
});
|
||||
}
|
||||
|
||||
test("a note that already names the running bot is unchanged by its id", () => {
|
||||
expect(botFilesNote({ bot_id: 5 })).toContain("[folder:bot:5:reports]");
|
||||
});
|
||||
|
||||
test("the computer files note keeps naming the computer it was given", () => {
|
||||
expect(ComputerFilesNote({ computer_id: 8 })).toContain("[folder:computer:8:seo/coderank/raw]");
|
||||
});
|
||||
|
||||
test("notes with nothing bot-specific carry no ref example", () => {
|
||||
expect(TasksNote).not.toContain("[folder:bot:");
|
||||
expect(TasksNote).not.toContain("[bot:");
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,19 @@ export const ReadImagePrompt = `Describe this image for someone who can't see it
|
||||
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
|
||||
|
||||
/**
|
||||
* Added to a bot's prompt when it answers the user in chat.
|
||||
* The folder-ref example a note shows, always over the running bot's own
|
||||
* folder: a literal owner id in the prompt teaches the bot to name another
|
||||
* bot's folder as if it were its own.
|
||||
*/
|
||||
export const ChatReplyNote = `# Replying in chat
|
||||
function ownFolderRef({ bot_id }: { bot_id: number | string }) {
|
||||
return folderRef({ volume: "bot", owner_id: bot_id, path: "reports" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Added to a bot's prompt when it answers the user in chat. `bot_id` is the
|
||||
* bot itself: the examples it reads are its own refs.
|
||||
*/
|
||||
export const chatReplyNote = ({ bot_id }: { bot_id: number | string }) => `# Replying in chat
|
||||
|
||||
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
|
||||
|
||||
@@ -33,7 +43,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
|
||||
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
|
||||
- Never claim you did something you didn't do.
|
||||
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
|
||||
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: that opens the folder in Files and downloads nothing.
|
||||
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${ownFolderRef({ bot_id })}: that opens the folder in Files and downloads nothing.
|
||||
|
||||
## Keep the work moving
|
||||
|
||||
@@ -41,9 +51,10 @@ Be proactive. Follow where the conversation is heading and, once you've answered
|
||||
|
||||
/**
|
||||
* Added to every run, chat replies and tasks alike: where its files live,
|
||||
* that they persist, and how to search them.
|
||||
* that they persist, and how to search them. `bot_id` is the bot itself,
|
||||
* so the folder-ref example it reads is its own folder.
|
||||
*/
|
||||
export const BotFilesNote = `# Your files
|
||||
export const botFilesNote = ({ bot_id }: { bot_id: number | string }) => `# Your files
|
||||
|
||||
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
|
||||
|
||||
@@ -53,7 +64,7 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
|
||||
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
|
||||
- A file and a folder are two different refs, and you never guess which one from the name:
|
||||
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
|
||||
- A folder is a folder ref, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
||||
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
||||
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
|
||||
- A file you only described is not saved. Save it first, then say it's saved.
|
||||
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
|
||||
@@ -114,8 +125,10 @@ Every action counts here: saving memory or instructions, creating or changing a
|
||||
|
||||
/**
|
||||
* Added to the coordinator's chat prompt: it hands work to the crew.
|
||||
* `bot_id` is the coordinator itself, so the ref example it copies is its
|
||||
* own rather than a worker's.
|
||||
*/
|
||||
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: 3 })}: the user sees it as the bot's name, linked to its conversation.`;
|
||||
export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: bot_id })} for yourself: the user sees it as the bot's name, linked to its conversation.`;
|
||||
|
||||
/**
|
||||
* Added to the coordinator's chat prompt instead while it has no workers,
|
||||
|
||||
@@ -110,6 +110,12 @@ export const CoordinatorBotName = "Coordinator";
|
||||
*/
|
||||
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
|
||||
|
||||
/**
|
||||
* The `app_settings` key holding the install's own limit on the tool turns
|
||||
* one chat reply may take. Unset means `AppData.BotReplyMaxToolSteps`.
|
||||
*/
|
||||
export const BotReplyMaxToolStepsSettingKey = "bot_reply_max_tool_steps";
|
||||
|
||||
/**
|
||||
* A bot's face is its status. There are no badges or spinners.
|
||||
*/
|
||||
|
||||
@@ -53,6 +53,12 @@ export const Harnesses = [
|
||||
|
||||
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
||||
|
||||
/**
|
||||
* The `app_settings` key holding the install's own limit on model turns in
|
||||
* one run (a task). Unset means `AppData.HarnessMaxToolSteps`.
|
||||
*/
|
||||
export const HarnessMaxToolStepsSettingKey = "harness_max_tool_steps";
|
||||
|
||||
/**
|
||||
* Vendors preferred for automatic picks, best first. Free models from any
|
||||
* other vendor come after these, and only if they look competent.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { BotReplyMaxToolStepsSettingKey } from "@/src/dict/bot-dict";
|
||||
import { HarnessMaxToolStepsSettingKey } from "@/src/dict/harness-dict";
|
||||
import readStepLimit from "@/src/utils/read-step-limit";
|
||||
import grabAppSetting from "./grab-app-setting";
|
||||
|
||||
/**
|
||||
* Model turns a task run may take: the install's own limit
|
||||
* (`harness_max_tool_steps`, Settings > General), else
|
||||
* `AppData.HarnessMaxToolSteps`. `runTextLoop`'s default is the code's one,
|
||||
* so a task run passes this in.
|
||||
*/
|
||||
export async function grabHarnessMaxToolSteps() {
|
||||
return readStepLimit({
|
||||
saved: await grabAppSetting({ key: HarnessMaxToolStepsSettingKey }),
|
||||
fallback: AppData.HarnessMaxToolSteps,
|
||||
max: AppData.BotStepLimitMax,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Model turns a chat reply may take: the install's own limit
|
||||
* (`bot_reply_max_tool_steps`, Settings > General), else
|
||||
* `AppData.BotReplyMaxToolSteps`.
|
||||
*/
|
||||
export async function grabReplyMaxToolSteps() {
|
||||
return readStepLimit({
|
||||
saved: await grabAppSetting({ key: BotReplyMaxToolStepsSettingKey }),
|
||||
fallback: AppData.BotReplyMaxToolSteps,
|
||||
max: AppData.BotStepLimitMax,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Both limits with their defaults, for the settings page.
|
||||
*/
|
||||
export default async function grabRunLimitsOverview() {
|
||||
const [task_steps, reply_steps] = await Promise.all([
|
||||
grabHarnessMaxToolSteps(),
|
||||
grabReplyMaxToolSteps(),
|
||||
]);
|
||||
|
||||
const overview: RunLimitsOverview = {
|
||||
task_steps,
|
||||
reply_steps,
|
||||
task_default: AppData.HarnessMaxToolSteps,
|
||||
reply_default: AppData.BotReplyMaxToolSteps,
|
||||
max: AppData.BotStepLimitMax,
|
||||
};
|
||||
|
||||
return overview;
|
||||
}
|
||||
|
||||
export type RunLimitsOverview = {
|
||||
/**
|
||||
* The limit a task run uses right now.
|
||||
*/
|
||||
task_steps: number;
|
||||
/**
|
||||
* The limit a chat reply uses right now.
|
||||
*/
|
||||
reply_steps: number;
|
||||
/**
|
||||
* What a task run uses when the install saved no limit.
|
||||
*/
|
||||
task_default: number;
|
||||
/**
|
||||
* What a chat reply uses when the install saved no limit.
|
||||
*/
|
||||
reply_default: number;
|
||||
/**
|
||||
* The most the settings page accepts.
|
||||
*/
|
||||
max: number;
|
||||
};
|
||||
@@ -19,10 +19,10 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The full prompt for one of the user's bots (`composeBotPrompt`): its
|
||||
* core instructions, the install-wide appendix, its memory, what it
|
||||
* remembers about this user, and in a workspace that workspace's context
|
||||
* and what the bot has for it.
|
||||
* The full prompt for one of the user's bots (`composeBotPrompt`): who it
|
||||
* is (its name and id), its core instructions, the install-wide appendix,
|
||||
* its memory, what it remembers about this user, and in a workspace that
|
||||
* workspace's context and what the bot has for it.
|
||||
*/
|
||||
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
|
||||
const bot = await grabUserBot({ bot_id, user_id });
|
||||
@@ -40,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
|
||||
}
|
||||
|
||||
return composeBotPrompt({
|
||||
bot: { id: bot.id, name: bot.name, kind: bot.kind, role: bot.role },
|
||||
system_prompt: bot.system_prompt,
|
||||
appendix: await grabSystemPromptAppend(),
|
||||
memory: bot.memory,
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function createTaskTool({
|
||||
name: "create_task",
|
||||
description: is_coordinator
|
||||
? `Add a task to a list: a worker's own (${crew || "no workers yet"}), a workspace's shared one (${places || "no workspaces yet"}), or your own (leave both ids out). Write the description as a brief: what to do, where, and how you'll know it's done. A workspace's tasks are visible to every bot working there.`
|
||||
: `Add a task to a list: ${workspace_id ? "this workspace's shared one (leave the ids out), or your own (bot_id: " + bot_id + ")" : "your own (leave the ids out)"}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
|
||||
: `Add a task to a list: ${workspace_id ? `this workspace's shared one (leave the ids out), or your own (bot_id: ${bot_id})` : `your own, bot_id ${bot_id} (or leave the id out, since it defaults to yours)`}. 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
|
||||
@@ -111,7 +111,7 @@ export default function createTaskTool({
|
||||
bot_id: z.number().int().optional().describe(
|
||||
is_coordinator
|
||||
? `Whose own list, when it isn't a workspace's. Leave out for your own.`
|
||||
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's.`,
|
||||
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's; leaving it out has the same effect here.`,
|
||||
),
|
||||
workspace_id: z
|
||||
.number()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import type { BotComputers } from "../(hooks)/use-bot-computers";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
|
||||
const main: BUN_SQLITE_COBBLE_COMPUTERS = { id: 1, name: "Main computer" };
|
||||
const sandbox: BUN_SQLITE_COBBLE_COMPUTERS = { id: 2, name: "SEO box" };
|
||||
|
||||
type RenderProps = {
|
||||
computer_id: number;
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
};
|
||||
|
||||
function render({ computer_id, computers }: RenderProps) {
|
||||
const value = makeComputers({ computers });
|
||||
|
||||
const html = renderToString(
|
||||
<BotComputersContext.Provider value={value}>
|
||||
<ComputerRefChip computer_id={computer_id} />
|
||||
</BotComputersContext.Provider>,
|
||||
);
|
||||
|
||||
return { html, value };
|
||||
}
|
||||
|
||||
type MakeComputersProps = {
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A computers context recording what the chip asked for: the viewer it
|
||||
* opened and the switches it made.
|
||||
*/
|
||||
function makeComputers({ computers }: MakeComputersProps) {
|
||||
const switches: number[] = [];
|
||||
const opened: { computer_id: number; mode: string }[] = [];
|
||||
|
||||
const value: BotComputers = {
|
||||
computers,
|
||||
switchComputer: async ({ computer_id }) => {
|
||||
switches.push(computer_id);
|
||||
},
|
||||
openComputer: (params) => {
|
||||
opened.push(params);
|
||||
},
|
||||
closeComputer: () => {},
|
||||
};
|
||||
|
||||
return Object.assign(value, { switches, opened });
|
||||
}
|
||||
|
||||
describe("ComputerRefChip", () => {
|
||||
test("opens the viewer in its browser and switches nothing", async () => {
|
||||
const { html, value } = render({
|
||||
computer_id: 2,
|
||||
computers: [main, sandbox],
|
||||
});
|
||||
|
||||
expect(html).toContain("SEO box");
|
||||
// The chip's only job is opening, so no switch affordance survives.
|
||||
expect(html).not.toContain("Switch to");
|
||||
expect(value.switches).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("the computer the bot is on is still a chip, sunken with a check", () => {
|
||||
const { html } = render({ computer_id: 1, computers: [main, sandbox] });
|
||||
|
||||
expect(html).toContain("Main computer");
|
||||
expect(html).toContain("<button");
|
||||
expect(html).toContain("bg-sunken");
|
||||
expect(html).toContain('title="Open Main computer"');
|
||||
});
|
||||
|
||||
test("a computer the bot can't use stays a plain name", () => {
|
||||
const { html } = render({ computer_id: 9, computers: [main] });
|
||||
|
||||
expect(html).toContain("computer 9");
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useContext } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Check, Monitor } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
|
||||
type Props = {
|
||||
@@ -9,33 +10,22 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A computer the bot named in a message (`computerRef`): a button that
|
||||
* switches the bot to it at once, or a checked chip when it's already on
|
||||
* it. A computer the bot can't use (any more) is shown by name only.
|
||||
* A computer the bot named in a message (`computerRef`): a chip that opens
|
||||
* it in the viewer, in its browser. The one the bot is already on says so,
|
||||
* sunken with a check. A computer the bot can't use (any more) is shown by
|
||||
* name only.
|
||||
*/
|
||||
export default function ComputerRefChip({ computer_id }: Props) {
|
||||
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
|
||||
const { computers, openComputer } = useContext(BotComputersContext);
|
||||
|
||||
const index = computers.findIndex((c) => c.id == computer_id);
|
||||
const computer = computers[index];
|
||||
const computer = computers.find((c) => c.id == computer_id);
|
||||
const name = computer?.name || `computer ${computer_id}`;
|
||||
|
||||
if (!computer) {
|
||||
if (!computer?.id) {
|
||||
return <Span className="font-bold">{name}</Span>;
|
||||
}
|
||||
|
||||
if (index == 0) {
|
||||
return (
|
||||
<Span
|
||||
title="The computer this bot is on"
|
||||
className="inline-flex h-6 gap-1.5 items-center px-2 align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap"
|
||||
>
|
||||
<Monitor size={14} aria-hidden />
|
||||
{name}
|
||||
<Check size={14} aria-hidden />
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
const current = computers[0]?.id == computer.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -43,14 +33,20 @@ export default function ComputerRefChip({ computer_id }: Props) {
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title={error && switching_id == undefined ? error : `Switch to ${name}`}
|
||||
loading={switching_id == computer_id}
|
||||
disabled={Boolean(switching_id)}
|
||||
onClick={() => switchComputer({ computer_id })}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
title={`Open ${name}`}
|
||||
onClick={() =>
|
||||
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",
|
||||
current
|
||||
? "bg-sunken hover:bg-sunken outline-0"
|
||||
: "bg-transparent hover:bg-sunken",
|
||||
)}
|
||||
>
|
||||
<Monitor size={14} aria-hidden className="shrink-0" />
|
||||
{name}
|
||||
{current ? <Check size={14} aria-hidden className="shrink-0" /> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio"
|
||||
import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-password-hash";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabRunLimitsOverview from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
|
||||
import runningVersion from "@/src/utils/running-version";
|
||||
@@ -45,6 +46,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
|
||||
has_password,
|
||||
storage_quota: await grabStorageQuotaOverview().catch(() => null),
|
||||
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
|
||||
run_limits: await grabRunLimitsOverview().catch(() => null),
|
||||
running_version: runningVersion({ env: process.env }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { AppContextObject, PagePropsType } from "@/src/types";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsShell from "./settings-shell";
|
||||
|
||||
type RenderProps = {
|
||||
pathname: string;
|
||||
/**
|
||||
* The page's own title, the name the header shows.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
function render({ pathname, title }: RenderProps) {
|
||||
const pageProps: PagePropsType = {
|
||||
url: { pathname } as PagePropsType["url"],
|
||||
};
|
||||
const context = { pageProps } as AppContextObject;
|
||||
|
||||
return renderToString(
|
||||
<AppContext.Provider value={context}>
|
||||
<SettingsShell
|
||||
title={title}
|
||||
description="Work your bots do on a schedule."
|
||||
>
|
||||
<span>PAGE_CONTENT</span>
|
||||
</SettingsShell>
|
||||
</AppContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsShell breadcrumbs", () => {
|
||||
test("a top-level page shows Home and itself", () => {
|
||||
const html = render({ pathname: "/admin/routines", title: "Routines" });
|
||||
|
||||
expect(html).toContain('aria-label="Breadcrumb"');
|
||||
expect(html).toContain(">Home<");
|
||||
expect(html).toContain(">Routines<");
|
||||
expect(html).toContain('href="/admin/routines"');
|
||||
});
|
||||
|
||||
test("a settings page walks through Settings up to the page", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/general",
|
||||
title: "General",
|
||||
});
|
||||
|
||||
expect(html).toContain('href="/admin"');
|
||||
expect(html).toContain('href="/admin/settings"');
|
||||
expect(html).toContain('href="/admin/settings/general"');
|
||||
});
|
||||
|
||||
test("a record page reads its name, not the id in its address", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/bots/3",
|
||||
title: "Scout",
|
||||
});
|
||||
|
||||
expect(html).toContain(">Scout<");
|
||||
expect(html).not.toContain(">3<");
|
||||
});
|
||||
|
||||
test("a page whose slug differs from its name shows the name", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/change-password",
|
||||
title: "Change password",
|
||||
});
|
||||
|
||||
expect(html).toContain(">Change password<");
|
||||
expect(html).not.toContain(">Change Password<");
|
||||
});
|
||||
|
||||
test("Activity keeps its name though its address says logs", () => {
|
||||
const html = render({ pathname: "/admin/logs", title: "Activity" });
|
||||
|
||||
expect(html).toContain(">Activity<");
|
||||
expect(html).not.toContain(">Logs<");
|
||||
});
|
||||
|
||||
test("the trail sits above the page's content, right after the header", () => {
|
||||
const html = render({ pathname: "/admin/routines", title: "Routines" });
|
||||
|
||||
expect(html.indexOf('aria-label="Breadcrumb"')).toBeLessThan(
|
||||
html.indexOf("PAGE_CONTENT"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the crew of bots, which is home, has no trail above it", () => {
|
||||
const html = render({ pathname: "/admin/bots", title: "Bots" });
|
||||
|
||||
expect(html).not.toContain('aria-label="Breadcrumb"');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The record's name, shown where the URL has its id.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The hierarchy above a nested settings page: Settings / Bots / Scout.
|
||||
* Built from the path, with the record's name replacing its id.
|
||||
*/
|
||||
export default function SettingsBreadcrumbs({ title }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const pathname = pageProps?.url?.pathname;
|
||||
|
||||
if (!pathname) return null;
|
||||
|
||||
return (
|
||||
<Stack className="w-full shrink-0 pt-5 pb-1">
|
||||
<Breadcrumbs
|
||||
pageUrl={pathname}
|
||||
currentTitle={title}
|
||||
skipHome
|
||||
excludeRegexMatch={/^admin$/}
|
||||
linkProps={{
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||
}}
|
||||
currentLinkProps={{
|
||||
title,
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||
}}
|
||||
divider={
|
||||
<ChevronRight
|
||||
size={12}
|
||||
aria-hidden
|
||||
className="shrink-0 text-ink-secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import SettingsBreadcrumbs from "./settings-breadcrumbs";
|
||||
import AdminBreadcrumbs from "@/src/components/general/admin-breadcrumbs";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsPaneHeader from "./settings-pane-header";
|
||||
|
||||
type Props = {
|
||||
@@ -12,10 +13,6 @@ type Props = {
|
||||
* A wider column, for tables (activity).
|
||||
*/
|
||||
wide?: boolean;
|
||||
/**
|
||||
* A nested record page's name, for its breadcrumb trail.
|
||||
*/
|
||||
breadcrumb_title?: string;
|
||||
/**
|
||||
* Controls at the end of the pane header, e.g. a settings link.
|
||||
*/
|
||||
@@ -23,17 +20,19 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings pane beside the rail: a 64px header like the conversation
|
||||
* header, then one readable column that scrolls on its own.
|
||||
* The pane beside the rail: a 64px header like the conversation header,
|
||||
* then one readable column that scrolls on its own, opening with the
|
||||
* breadcrumb trail above the page.
|
||||
*/
|
||||
export default function SettingsShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
wide,
|
||||
breadcrumb_title,
|
||||
action,
|
||||
}: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<SettingsPaneHeader
|
||||
@@ -48,9 +47,15 @@ export default function SettingsShell({
|
||||
wide && "max-w-[1200px]",
|
||||
)}
|
||||
>
|
||||
{breadcrumb_title ? (
|
||||
<SettingsBreadcrumbs title={breadcrumb_title} />
|
||||
) : null}
|
||||
{/**
|
||||
* The page's own `title` names the last crumb: the
|
||||
* address would say the id of a record, or a slug that
|
||||
* differs from the name the page shows.
|
||||
*/}
|
||||
<AdminBreadcrumbs
|
||||
pathname={pageProps?.url?.pathname}
|
||||
title={title}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function BotSettingsSection() {
|
||||
? "Coordinates your crew and hands work to it"
|
||||
: "A worker bot.")
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<BotOverviewGroup bot={bot} />
|
||||
<BotDetailsGroup
|
||||
|
||||
-1
@@ -45,7 +45,6 @@ export default function ComputerSettingsSection() {
|
||||
computer.description ||
|
||||
"Its container, the bots that reach it, and its workspaces."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<ComputerOverviewGroup computer={computer} />
|
||||
{is_super_admin ? (
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from "react";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { RunLimitsOverview } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||
|
||||
type Props = {
|
||||
limits?: RunLimitsOverview | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many model turns a task run and a chat reply may take, saved on its
|
||||
* own without a reload. Zero puts the code's default back for that one.
|
||||
*/
|
||||
export default function RunLimitsForm({ limits }: Props) {
|
||||
const [overview, setOverview] = useState(limits || undefined);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [task_steps, setTaskSteps] = useState(String(limits?.task_steps ?? ""));
|
||||
const [reply_steps, setReplySteps] = useState(String(limits?.reply_steps ?? ""));
|
||||
/**
|
||||
* The inputs only read `defaultValue` when they mount, so discarding or
|
||||
* saving remounts them with the saved numbers.
|
||||
*/
|
||||
const [form_key, setFormKey] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
msg?: string;
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
}>();
|
||||
|
||||
const save = async () => {
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<RunLimitsOverview>>(
|
||||
"/api/admin/settings/run-limits",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
task_steps: Number(task_steps) || 0,
|
||||
reply_steps: Number(reply_steps) || 0,
|
||||
},
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({
|
||||
error: true,
|
||||
msg: res?.msg || `Couldn't save the limits.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setOverview(res.singleRes);
|
||||
setTaskSteps(String(res.singleRes.task_steps));
|
||||
setReplySteps(String(res.singleRes.reply_steps));
|
||||
setFormKey((key) => key + 1);
|
||||
setDirty(false);
|
||||
setStatus({
|
||||
success: true,
|
||||
msg: `Saved: ${res.singleRes.task_steps} steps per task, ${res.singleRes.reply_steps} per reply.`,
|
||||
});
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setTaskSteps(String(overview?.task_steps ?? ""));
|
||||
setReplySteps(String(overview?.reply_steps ?? ""));
|
||||
setFormKey((key) => key + 1);
|
||||
setDirty(false);
|
||||
setStatus(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save().catch(() => {});
|
||||
}}
|
||||
className="w-full gap-3! items-stretch"
|
||||
>
|
||||
<Stack className="w-full gap-3! items-stretch">
|
||||
<Input
|
||||
key={`task-${form_key}`}
|
||||
label="Tool steps per task"
|
||||
type="number"
|
||||
min={0}
|
||||
max={overview?.max}
|
||||
step={1}
|
||||
numberText
|
||||
rawNumber
|
||||
defaultValue={task_steps}
|
||||
onChange={(event) => {
|
||||
setTaskSteps(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
suffix={`steps`}
|
||||
info={`How many turns the model may take before it must stop and report, when it works on a task. 0 uses the default (${overview?.task_default ?? ""}).`}
|
||||
/>
|
||||
<Input
|
||||
key={`reply-${form_key}`}
|
||||
label="Tool steps per chat reply"
|
||||
type="number"
|
||||
min={0}
|
||||
max={overview?.max}
|
||||
step={1}
|
||||
numberText
|
||||
rawNumber
|
||||
defaultValue={reply_steps}
|
||||
onChange={(event) => {
|
||||
setReplySteps(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
suffix={`steps`}
|
||||
info={`How many turns a chat reply may take before it answers. Long work belongs in a task. 0 uses the default (${overview?.reply_default ?? ""}).`}
|
||||
/>
|
||||
</Stack>
|
||||
<SettingsSaveBar
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
status={status}
|
||||
save_label="Save limits"
|
||||
onDiscard={discard}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import RunLimitsForm from "../(partials)/run-limits-form";
|
||||
|
||||
/**
|
||||
* How long a bot may work on one task or reply before it reports. Only the
|
||||
* super admin sees it, since it changes every bot on the install.
|
||||
*/
|
||||
export default function RunLimitsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Run limits"
|
||||
description="How many turns a bot may take with its tools before it must stop and report. A task may take far more than a chat reply, since it does the long work."
|
||||
id="run-limits"
|
||||
>
|
||||
<RunLimitsForm limits={pageProps?.run_limits} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppearanceSection from "./(sections)/appearance-section";
|
||||
import NotificationsSection from "./(sections)/notifications-section";
|
||||
import PromptSection from "./(sections)/prompt-section";
|
||||
import RunLimitsSection from "./(sections)/run-limits-section";
|
||||
import StorageSection from "./(sections)/storage-section";
|
||||
import VersionSection from "./(sections)/version-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
@@ -15,6 +16,7 @@ export default function AdminSettingsGeneralPage() {
|
||||
<AppearanceSection />
|
||||
<NotificationsSection />
|
||||
<PromptSection />
|
||||
<RunLimitsSection />
|
||||
<StorageSection />
|
||||
<VersionSection />
|
||||
</SettingsShell>
|
||||
|
||||
@@ -4,7 +4,10 @@ import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsMcpsPage() {
|
||||
return (
|
||||
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
|
||||
<SettingsShell
|
||||
title="MCPs"
|
||||
description="MCP servers whose tools your bots can use."
|
||||
>
|
||||
<McpsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function UserSettingsSection() {
|
||||
description={
|
||||
types_title || person.email || "A user on this install."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<UserOverviewGroup
|
||||
person={person}
|
||||
|
||||
@@ -50,7 +50,6 @@ export default function WorkspaceOverview() {
|
||||
workspace.description ||
|
||||
"Context, bots, computers and models for this workspace."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<WorkspaceImageGroup workspace={workspace} />
|
||||
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabRunLimitsOverview, {
|
||||
type RunLimitsOverview,
|
||||
} from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { BotReplyMaxToolStepsSettingKey } from "@/src/dict/bot-dict";
|
||||
import { HarnessMaxToolStepsSettingKey } from "@/src/dict/harness-dict";
|
||||
import { SaveRunLimitsRequestSchema } from "@/src/schemas/harness-schema";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Super admin only: how many model turns a task run and a chat reply may
|
||||
* take. Zero puts the code's default back for that one.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<RunLimitsOverview>> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) throw new AppError(`Only a super admin can change the run limits.`);
|
||||
|
||||
const { task_steps, reply_steps } = parseWithSchema({
|
||||
schema: SaveRunLimitsRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
await saveAppSetting({
|
||||
key: HarnessMaxToolStepsSettingKey,
|
||||
value: task_steps > 0 ? String(task_steps) : null,
|
||||
});
|
||||
await saveAppSetting({
|
||||
key: BotReplyMaxToolStepsSettingKey,
|
||||
value: reply_steps > 0 ? String(reply_steps) : null,
|
||||
});
|
||||
|
||||
await logEvent({
|
||||
title: "Bot run limits changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} set the tool step limits to ${task_steps || "default"} per task and ${reply_steps || "default"} per chat reply.`,
|
||||
metadata: { task_steps, reply_steps },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, singleRes: await grabRunLimitsOverview() };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { Harnesses, type HarnessKind } from "@/src/dict/harness-dict";
|
||||
import {
|
||||
HarnessProviderKinds,
|
||||
@@ -96,6 +97,17 @@ export type SaveBotHarnessSettingsRequest = z.infer<
|
||||
typeof SaveBotHarnessSettingsRequestSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* The General settings' run limits: model turns a task run and a chat reply
|
||||
* may take. Zero puts the code's default back.
|
||||
*/
|
||||
export const SaveRunLimitsRequestSchema = z.object({
|
||||
task_steps: z.coerce.number().int().min(0).max(AppData.BotStepLimitMax),
|
||||
reply_steps: z.coerce.number().int().min(0).max(AppData.BotStepLimitMax),
|
||||
});
|
||||
|
||||
export type SaveRunLimitsRequest = z.infer<typeof SaveRunLimitsRequestSchema>;
|
||||
|
||||
export const PreviewVoiceRequestSchema = z.object({
|
||||
model_id: z.coerce.number().int().positive("Pick a speech model"),
|
||||
voice: z.string().trim().max(200).optional(),
|
||||
|
||||
@@ -1192,6 +1192,17 @@ export type PagePropsType = {
|
||||
setting_key: string;
|
||||
text: string;
|
||||
} | null;
|
||||
/**
|
||||
* Model turns a task run and a chat reply may take, with the code's
|
||||
* defaults (the General settings page).
|
||||
*/
|
||||
run_limits?: {
|
||||
task_steps: number;
|
||||
reply_steps: number;
|
||||
task_default: number;
|
||||
reply_default: number;
|
||||
max: number;
|
||||
} | null;
|
||||
/**
|
||||
* The release the server is running, for the General settings page.
|
||||
*/
|
||||
|
||||
@@ -2,9 +2,47 @@ import { describe, expect, test } from "bun:test";
|
||||
import composeBotPrompt from "./compose-bot-prompt";
|
||||
|
||||
describe("composeBotPrompt", () => {
|
||||
test("opens with who the bot is, its id included", () => {
|
||||
expect(composeBotPrompt({ bot: { id: 7, name: "Scout", kind: "worker", role: "Finds things" } })).toBe(
|
||||
[
|
||||
"# You are Scout",
|
||||
"You are a worker bot on this crew. Your role: Finds things.",
|
||||
"Your bot id is 7. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:7]`, never another bot's.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("knows the coordinator by its kind and keeps an unknown name", () => {
|
||||
expect(composeBotPrompt({ bot: { id: "1", kind: "coordinator" } })).toBe(
|
||||
[
|
||||
"# You are A bot",
|
||||
"You are the coordinator on this crew.",
|
||||
"Your bot id is 1. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:1]`, never another bot's.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("says nothing about identity when the caller gives no bot", () => {
|
||||
expect(composeBotPrompt({ bot: { id: null, name: "Scout" }, system_prompt: "Core." })).toBe(
|
||||
"Core.",
|
||||
);
|
||||
});
|
||||
|
||||
test("puts the identity above the core instructions", () => {
|
||||
expect(composeBotPrompt({ bot: { id: 4, name: "Scout" }, system_prompt: "Core." })).toBe(
|
||||
[
|
||||
"# You are Scout",
|
||||
"You are a worker bot on this crew.",
|
||||
"Your bot id is 4. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:4]`, never another bot's.",
|
||||
"Core.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("orders core instructions, the workspace, then memory", () => {
|
||||
expect(
|
||||
composeBotPrompt({
|
||||
bot: { id: 7, name: "Scout" },
|
||||
system_prompt: "# You are Scout\n\nFind things.",
|
||||
memory: "- Cite sources.",
|
||||
user_memory: "- The user prefers short replies.",
|
||||
@@ -20,6 +58,9 @@ describe("composeBotPrompt", () => {
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"# You are Scout",
|
||||
"You are a worker bot on this crew.",
|
||||
"Your bot id is 7. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:7]`, never another bot's.",
|
||||
"# You are Scout\n\nFind things.",
|
||||
`# Workspace: Launch\n\nYou're working in the workspace "Launch". Follow its context and your instructions for it below, over your general habits where they differ.\n\nThe workspace's context, which the user sets:\n\nThe spring launch.\n\nShip by Friday.`,
|
||||
"# Your instructions in Launch\n\nOwn the changelog.",
|
||||
@@ -33,6 +74,7 @@ describe("composeBotPrompt", () => {
|
||||
test("puts the install-wide appendix right after the core instructions", () => {
|
||||
expect(
|
||||
composeBotPrompt({
|
||||
bot: { id: 4, name: "Scout" },
|
||||
system_prompt: "Core.",
|
||||
appendix: "Never invent sources.",
|
||||
workspace: { name: "Launch", description: "The spring launch." },
|
||||
@@ -40,6 +82,9 @@ describe("composeBotPrompt", () => {
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"# You are Scout",
|
||||
"You are a worker bot on this crew.",
|
||||
"Your bot id is 4. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref `[bot:4]`, never another bot's.",
|
||||
"Core.",
|
||||
"# Install-wide instructions\n\nNever invent sources.",
|
||||
`# Workspace: Launch\n\nYou're working in the workspace "Launch". Follow its context and your instructions for it below, over your general habits where they differ.\n\nThe workspace's context, which the user sets:\n\nThe spring launch.`,
|
||||
|
||||
@@ -9,7 +9,27 @@ type WorkspaceBotPrompt = {
|
||||
memory?: string | null;
|
||||
};
|
||||
|
||||
type BotPrompt = {
|
||||
/**
|
||||
* The bot's id: how it names itself, and how the user, the crew tools
|
||||
* and every ref in a message point at it.
|
||||
*/
|
||||
id?: number | string | null;
|
||||
name?: string | null;
|
||||
kind?: string | null;
|
||||
/**
|
||||
* The one-line role it does that job as, when it has one.
|
||||
*/
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The bot itself. Its id is always in the prompt: it is how the bot
|
||||
* names itself and how everything else (crew tools, refs, tasks,
|
||||
* files) points back at it, in chat replies, tasks and routines alike.
|
||||
*/
|
||||
bot?: BotPrompt | null;
|
||||
system_prompt?: string | null;
|
||||
/**
|
||||
* The install-wide text every bot gets, right after its own core
|
||||
@@ -32,14 +52,16 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The full prompt a bot runs with (markdown): its own core instructions,
|
||||
* then the install-wide appendix, then, in a workspace, the workspace's
|
||||
* The full prompt a bot runs with (markdown): who it is (its name and id,
|
||||
* so it always knows which bot it is), its own core instructions, then the
|
||||
* install-wide appendix, then, in a workspace, the workspace's
|
||||
* context and the bot's instructions there, then its core memory, its
|
||||
* memory about the user and its memory in the workspace (said to be the
|
||||
* bot's own notes, not the workspace's context). Empty parts are left out;
|
||||
* the workspace heading always shows, so the bot knows where it is.
|
||||
*/
|
||||
export default function composeBotPrompt({
|
||||
bot,
|
||||
system_prompt,
|
||||
appendix,
|
||||
memory,
|
||||
@@ -49,6 +71,11 @@ export default function composeBotPrompt({
|
||||
}: Params) {
|
||||
const parts: string[] = [];
|
||||
const name = workspace?.name?.trim() || "Untitled";
|
||||
const identity = composeIdentity({ bot });
|
||||
|
||||
if (identity) {
|
||||
parts.push(identity);
|
||||
}
|
||||
|
||||
if (system_prompt?.trim()) {
|
||||
parts.push(system_prompt.trim());
|
||||
@@ -95,3 +122,24 @@ export default function composeBotPrompt({
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the bot is, said once at the top of its prompt: its name, kind and
|
||||
* id, and that the id is how it is named. Empty when the caller didn't say
|
||||
* which bot it is.
|
||||
*/
|
||||
function composeIdentity({ bot }: { bot?: BotPrompt | null }) {
|
||||
const id = bot?.id === null || bot?.id === undefined ? "" : String(bot.id).trim();
|
||||
|
||||
if (!id) return "";
|
||||
|
||||
const name = bot?.name?.trim() || "A bot";
|
||||
const kind = bot?.kind == "coordinator" ? "the coordinator" : "a worker bot";
|
||||
const role = bot?.role?.trim();
|
||||
|
||||
return [
|
||||
`# You are ${name}`,
|
||||
`You are ${kind} on this crew.${role ? ` Your role: ${role}.` : ""}`,
|
||||
`Your bot id is ${id}. That id is you: when a tool takes a bot id for the bot it is about (your own tasks, your own picture), this is it, and when you name yourself in a message write the ref \`[bot:${id}]\`, never another bot's.`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import hasBreadcrumbTrail from "./has-breadcrumb-trail";
|
||||
|
||||
describe("hasBreadcrumbTrail", () => {
|
||||
test("a top-level page has one, so a lone page never goes without", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/routines" })).toBe(true);
|
||||
});
|
||||
|
||||
test("a nested page has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/general" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/bots/3" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("the crew of bots is home, so it has none", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("the address that only redirects to home has none either", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("a folder in the files browser keeps the trail of its page", () => {
|
||||
expect(
|
||||
hasBreadcrumbTrail({
|
||||
pathname: "/admin/files?volume=bot&owner=1&path=reports",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("nothing outside the admin pages has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/auth/login" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({})).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("a path that merely starts like admin does not count", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/administrator" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
|
||||
type Params = {
|
||||
pathname?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an address has a trail above it: `Home`, then every section it
|
||||
* passes through, then the page. `twui`'s `Breadcrumbs` draws the trail
|
||||
* itself from the path, so this only answers which pages get one.
|
||||
*
|
||||
* The crew of bots is where the app lands, and its own address would
|
||||
* read `Home / Bots`, both the same page, so it has none. Neither does
|
||||
* anything outside the admin pages, since everything else redirects to
|
||||
* the bots.
|
||||
*
|
||||
* The trailing slash and the query string are ignored, so a folder opened
|
||||
* in the files browser keeps the trail of the page it is on.
|
||||
*/
|
||||
export default function hasBreadcrumbTrail({ pathname }: Params): boolean {
|
||||
if (!pathname) return false;
|
||||
|
||||
const path = pathname.split("?")[0]!.replace(/\/$/, "");
|
||||
|
||||
if (path != "/admin" && !path.startsWith("/admin/")) return false;
|
||||
|
||||
/**
|
||||
* `/admin` only redirects to the crew of bots, which is home.
|
||||
*/
|
||||
return path != AdminHomePath && path != "/admin";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import readStepLimit from "./read-step-limit";
|
||||
|
||||
const base = { fallback: 200, max: 1000 };
|
||||
|
||||
describe("readStepLimit", () => {
|
||||
test("uses a saved limit inside the allowed range", () => {
|
||||
expect(readStepLimit({ ...base, saved: "40" })).toBe(40);
|
||||
expect(readStepLimit({ ...base, saved: " 12 " })).toBe(12);
|
||||
expect(readStepLimit({ ...base, saved: "1" })).toBe(1);
|
||||
expect(readStepLimit({ ...base, saved: "1000" })).toBe(1000);
|
||||
});
|
||||
|
||||
test("falls back when nothing was saved", () => {
|
||||
expect(readStepLimit({ ...base, saved: undefined })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: null })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: " " })).toBe(200);
|
||||
});
|
||||
|
||||
test("falls back when the saved limit can't drive a run", () => {
|
||||
expect(readStepLimit({ ...base, saved: "0" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "-3" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "abc" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "12.5" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "NaN" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "12steps" })).toBe(200);
|
||||
});
|
||||
|
||||
test("caps at the ceiling, never past it", () => {
|
||||
expect(readStepLimit({ ...base, saved: "1001" })).toBe(200);
|
||||
expect(readStepLimit({ ...base, saved: "999999" })).toBe(200);
|
||||
expect(readStepLimit({ fallback: 8, max: 50, saved: "51" })).toBe(8);
|
||||
expect(readStepLimit({ fallback: 8, max: 50, saved: "50" })).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
type Params = {
|
||||
/**
|
||||
* What the install saved, as it came out of `app_settings`: undefined or
|
||||
* empty when it never did.
|
||||
*/
|
||||
saved?: string | null;
|
||||
/**
|
||||
* The code's own limit, used whenever the saved one can't be trusted.
|
||||
*/
|
||||
fallback: number;
|
||||
/**
|
||||
* The most a saved limit may ask for.
|
||||
*/
|
||||
max: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a tool-step limit saved in the settings, falling back to the code's
|
||||
* default when it is missing, not a whole number, below one, or above the
|
||||
* ceiling. The settings page and the saved row are both untrusted input:
|
||||
* without this a typo (or a zero) would stop every run before its first
|
||||
* turn, and a huge number would let one run alone run for hours.
|
||||
*/
|
||||
export default function readStepLimit({ saved, fallback, max }: Params) {
|
||||
const text = String(saved ?? "").trim();
|
||||
|
||||
if (!text) return fallback;
|
||||
|
||||
const value = Number(text);
|
||||
|
||||
if (!Number.isInteger(value) || value < 1 || value > max) return fallback;
|
||||
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user