Add bots files handling logic
This commit is contained in:
@@ -115,7 +115,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
|
|
||||||
### Installer and release
|
### Installer and release
|
||||||
|
|
||||||
`install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`).
|
`install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`, `bots`, `workspaces`, `mcp`).
|
||||||
|
|
||||||
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
||||||
|
|
||||||
@@ -142,23 +142,37 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
|
|||||||
- **Web Push:** `push_subscriptions` (one row per endpoint and user; the endpoint is unique and a capability URL, never sent to the client) and `user_presence` (one row per user, refreshed by the socket keepalive in `src/websocket/index.ts`). VAPID keys are app settings in the `envs` table (`VAPID_PUBLIC_KEY` not secret, `VAPID_PRIVATE_KEY` secret), generated by `ensureVapidKeys` on the main server's first start; the subject is `SiteData.SiteURL`. `pushBotNotice` is the one trigger: bot messages (never the user's) and a needs-you face change without a message. It is skipped while `user_presence.last_seen_at` is within `AppData.PushPresenceWindowMs`, so an app open anywhere wins. `src/dict/push-dict.ts` owns payload limits and tags (one conversation replaces its own notification); sending never blocks or fails the job, 404/410 removes the device, and other failures are recorded on the subscription. API in `api/admin/push/` (subscribe, unsubscribe, test, remove-device, overview); Settings > Notifications (`notifications/`) has the per-device switch, a test send, and every device this user has. Permission is only asked from the switch (a denial is hard to undo, and iOS requires the gesture); iOS also needs the installed app. Development has no service worker, so the page says notifications need the installed production app.
|
- **Web Push:** `push_subscriptions` (one row per endpoint and user; the endpoint is unique and a capability URL, never sent to the client) and `user_presence` (one row per user, refreshed by the socket keepalive in `src/websocket/index.ts`). VAPID keys are app settings in the `envs` table (`VAPID_PUBLIC_KEY` not secret, `VAPID_PRIVATE_KEY` secret), generated by `ensureVapidKeys` on the main server's first start; the subject is `SiteData.SiteURL`. `pushBotNotice` is the one trigger: bot messages (never the user's) and a needs-you face change without a message. It is skipped while `user_presence.last_seen_at` is within `AppData.PushPresenceWindowMs`, so an app open anywhere wins. `src/dict/push-dict.ts` owns payload limits and tags (one conversation replaces its own notification); sending never blocks or fails the job, 404/410 removes the device, and other failures are recorded on the subscription. API in `api/admin/push/` (subscribe, unsubscribe, test, remove-device, overview); Settings > Notifications (`notifications/`) has the per-device switch, a test send, and every device this user has. Permission is only asked from the switch (a denial is hard to undo, and iOS requires the gesture); iOS also needs the installed app. Development has no service worker, so the page says notifications need the installed production app.
|
||||||
- **Risks and notes:** push needs HTTPS (or localhost), so it depends on the proxy or tunnel in front. Cloudflare never caches `/sw.js` (no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignores `interactive-widget`, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed for `PushPresenceWindowMs` (deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap.
|
- **Risks and notes:** push needs HTTPS (or localhost), so it depends on the proxy or tunnel in front. Cloudflare never caches `/sw.js` (no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignores `interactive-widget`, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed for `PushPresenceWindowMs` (deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap.
|
||||||
|
|
||||||
|
### Bot files and folders
|
||||||
|
|
||||||
|
Bots keep files on the app's data volume, never on a computer: `DATA_DIR/bots/<bot_id>` for a bot's own, `DATA_DIR/workspaces/<workspace_id>` shared by the bots working there. The catalog is `bot_files` (scope `bot`|`workspace`, `owner_id`, `path`, `size`, `purpose`, `source`, `ingested_at`, `ingest_error`), its bytes are a private `media` row (paradigm `bot-file`) written by `recordBotFile` (`src/functions/backend/db/bot-files/record-bot-file.ts`), so they're served at `/media/private/<media id>` through `grabBotFileMedia` and deleted with the row.
|
||||||
|
|
||||||
|
Who reaches what: `grabStorageScopes` (`src/utils/storage-access.ts`) gives a worker its own folder plus the workspace's while it works there, and the coordinator every bot's and every workspace's. Enforced in the tools, never left to the prompt. `resolveBotPath` (`src/utils/resolve-bot-path.ts`) keeps every path inside its root (no absolute paths, `..`, NUL, symlink escapes) and the quota (`app_settings` key `bot_files_quota_mb`, set on Settings > General, `grabBotFileQuotaBytes`) is checked by `assertStorageRoom` before a write.
|
||||||
|
|
||||||
|
Tools in `src/harness/tools/storage/` (`grabBotFileTools`, every run: chat, tasks, routines): `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file` (only when the run reaches more than one folder), `generate_image` and `make_pdf`. A file written with `attach` rides on the reply: the outcome's `attachments` become `bot_message_media` rows on the result (`completeBotJob`). Bots name files as `[file:<id>]` (`src/utils/file-ref.ts`, linked by `linkComputerRefs`, rendered by `file-ref-chip.tsx`). Chat uploads are copied into the conversation's folder (`storeChatAttachments`) so a later run can search them.
|
||||||
|
|
||||||
|
Ingestion (`bot_file_chunks`, `bot_file_vectors` a vec0 table, `file_ingest_queue`): `ingestBotFile` extracts text (`extractBotFileText`, reusing `fileToText`; images and audio only on request), chunks it (`chunkText`), embeds it through the `embedding` harness and fits every vector to the fixed `AppData.EmbeddingDimensions` (`fitVectorDimensions`, pad or cut then normalize). The `file-ingest` worker (`src/file-ingest/`, started from `server.ts` next to the bots queue) works the queue; `ingest-pending-files` sweeps up anything stranded. With no embedding model the chunks are still stored and `searchBotFiles` falls back to word matching, always saying which it did.
|
||||||
|
|
||||||
|
Two sqlite-vec limits the code works around: a vec0 table's metadata columns are NOT NULL (a raw insert fills `created_at`/`updated_at` itself), and a side-car column can't be filtered in the same query as `MATCH` (the search pulls the nearest `k` and filters scopes in code). Never pass `--vector` to `bunx bun-sqlite schema` in `entrypoint.sh`: it would wipe every vector on each boot. Changing `AppData.EmbeddingDimensions` needs a manual `--vector` run plus a re-ingest.
|
||||||
|
|
||||||
|
A bot's or workspace's folder is removed with it (`purgeStorage`, called from `deleteBot` and `deleteWorkspace`, which read the media ids first).
|
||||||
|
|
||||||
### Harness (the model layer)
|
### Harness (the model layer)
|
||||||
|
|
||||||
`src/harness/` is the brain: every model call goes through it. Install-wide, managed by super admins at `/admin/settings/models`.
|
`src/harness/` is the brain: every model call goes through it. Install-wide, managed by super admins at `/admin/settings/models`.
|
||||||
|
|
||||||
- **Harnesses** (`Harnesses` in `src/dict/harness-dict.ts`): `text` (chats, plans, calls tools, drives computers; always on), `vision` ("Media reading": images/scans/screenshots for a text model that can't see), `transcription` (speech to text: speech-to-text models only, never chat models that hear audio), `speech` (text to speech: text-to-speech models only), `image` (image generation). Each has one `harness_settings` row (no row = automatic, on, with fallback): a `provider_id` (a `harness_providers` row the harness is limited to, for its pick and its automatic picks; null for any provider; with no row, the default provider, OpenRouter, `grabDefaultHarnessProviderId`), a pinned `model_id` (a `harness_models` row from that provider, not a foreign key) or null for automatic, `fallback` (also try the automatic picks), `enabled`, `options` (`voice`).
|
- **Harnesses** (`Harnesses` in `src/dict/harness-dict.ts`): `text` (chats, plans, calls tools, drives computers; always on), `vision` ("Media reading": images/scans/screenshots for a text model that can't see), `transcription` (speech to text: speech-to-text models only, never chat models that hear audio), `speech` (text to speech: text-to-speech models only), `image` (image generation), `embedding` (turns bot file chunks into vectors for `search_files`: embedding models only, never auto-picked, so it stays unavailable until a super admin picks one, and search falls back to words without it). Each has one `harness_settings` row (no row = automatic, on, with fallback): a `provider_id` (a `harness_providers` row the harness is limited to, for its pick and its automatic picks; null for any provider; with no row, the default provider, OpenRouter, `grabDefaultHarnessProviderId`), a pinned `model_id` (a `harness_models` row from that provider, not a foreign key) or null for automatic, `fallback` (also try the automatic picks), `enabled`, `options` (`voice`).
|
||||||
- **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`, `listModels`), chosen in `grab-harness-adapter.ts`; a provider needing a native API gets its own adapter there (`HarnessAdapter` in `src/harness/types.ts`).
|
- **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.
|
- **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.
|
- **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`. 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 `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`).
|
||||||
- **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`).
|
- **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/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; 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*`.
|
- **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; 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).
|
- **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).
|
||||||
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
|
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
|
||||||
- **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`.
|
- **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.
|
- **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.
|
- **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 and the job's computer tools, closing the MCP connection after. 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 `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`.
|
||||||
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
|
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
|
||||||
|
|
||||||
## Admin Route Hierarchy
|
## Admin Route Hierarchy
|
||||||
@@ -171,7 +185,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
|
|||||||
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
|
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
|
||||||
- Routines, 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
|
- Routines, 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
|
||||||
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
||||||
- `/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), `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/` and `tools/` (see MCPs and custom tools: 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`)), each 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; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`), `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/` and `tools/` (see MCPs and custom tools: 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`)), each 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`).
|
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`).
|
||||||
|
|
||||||
|
|||||||
+228
@@ -18,6 +18,7 @@ import { Harnesses } from "@/src/dict/harness-dict";
|
|||||||
import { HarnessProviderKinds } from "@/src/dict/harness-provider-dict";
|
import { HarnessProviderKinds } from "@/src/dict/harness-provider-dict";
|
||||||
import { BotJobKinds, BotJobUpdateKinds } from "@/src/dict/bot-queue-dict";
|
import { BotJobKinds, BotJobUpdateKinds } from "@/src/dict/bot-queue-dict";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { BotFilePurposes, BotFileScopes, BotFileSources } from "@/src/dict/bot-file-dict";
|
||||||
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
|
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
|
||||||
import { EnvParadigms } from "@/src/dict/env-dict";
|
import { EnvParadigms } from "@/src/dict/env-dict";
|
||||||
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
|
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
|
||||||
@@ -2322,6 +2323,233 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_files",
|
||||||
|
tableDescription: `A file a bot saved or the user gave it: the catalog row for bytes on the data volume, under \`DATA_DIR/bots/<id>\` or \`DATA_DIR/workspaces/<id>\`. The bytes are a private \`media\` row (paradigm \`bot-file\`), so they're served like other media and deleted with the row. Never on a computer's filesystem`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
fieldDescription: `The bot that wrote or received it. Goes with the bot`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_files_bot_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "scope",
|
||||||
|
fieldDescription: `Which directory it's in: a bot's own or a workspace's shared one`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: BotFileScopes.map((s) => s.value),
|
||||||
|
defaultValue: "bot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "owner_id",
|
||||||
|
fieldDescription: `The bot id (scope bot) or workspace id (scope workspace) owning the directory. Not a foreign key, so a row outlives a workspace but its files are cleaned up with it`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "media_id",
|
||||||
|
fieldDescription: `The private \`media\` row holding the bytes. Not a foreign key: the catalog row can outlive it (a failed cleanup) and is then listed as missing`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "path",
|
||||||
|
fieldDescription: `Path inside the directory, forward slashes, e.g. reports/q3.md`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "name",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "folder",
|
||||||
|
fieldDescription: `The folder part of \`path\`, empty at the top`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "mime_type",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "media_type",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: MediaTypes.map((it) => it.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "size",
|
||||||
|
fieldDescription: `Bytes on disk`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "purpose",
|
||||||
|
fieldDescription: `What it is for: an upload, something the bot wrote, a generated image or PDF`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: BotFilePurposes.map((p) => p.value),
|
||||||
|
defaultValue: "writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "source",
|
||||||
|
fieldDescription: `How it got here: the user attached it to a message, the bot wrote it, or a tool made it`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: BotFileSources.map((s) => s.value),
|
||||||
|
defaultValue: "bot-write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "ingested_at",
|
||||||
|
fieldDescription: `When its text was chunked and embedded. Null: not ingested (yet)`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "ingest_error",
|
||||||
|
fieldDescription: `Why the last ingestion failed, when it did`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueConstraints: [
|
||||||
|
{
|
||||||
|
alias: "one_file_per_path",
|
||||||
|
constraintName: "One file per directory and path",
|
||||||
|
constraintTableFields: [{ value: "scope" }, { value: "owner_id" }, { value: "path" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_files_owner",
|
||||||
|
indexTableFields: ["scope", "owner_id", "folder"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_files_media_id",
|
||||||
|
indexTableFields: ["media_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_file_chunks",
|
||||||
|
tableDescription: `A piece of a bot file's text, the unit a search returns. Replaced whole when the file is ingested again`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "file_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bot_files",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_file_chunks_file_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "scope",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: BotFileScopes.map((s) => s.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "owner_id",
|
||||||
|
fieldDescription: `The bot or workspace the file belongs to, so a search filters without a join`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "chunk_index",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "text",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "token_estimate",
|
||||||
|
fieldDescription: `A rough count (characters / 4), for trimming results`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_file_chunks_file_id",
|
||||||
|
indexTableFields: ["file_id", "chunk_index"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_file_chunks_owner",
|
||||||
|
indexTableFields: ["scope", "owner_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_file_vectors",
|
||||||
|
tableDescription: `sqlite-vec vectors of a file's chunks, at \`AppData.EmbeddingDimensions\` wide whatever the model returns. A vec0 table is only recreated with \`bunx bun-sqlite schema --vector\`, so the entrypoint never passes it`,
|
||||||
|
isVector: true,
|
||||||
|
vectorType: "vec0",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "embedding",
|
||||||
|
fieldDescription: `Normalized, padded or cut to \`AppData.EmbeddingDimensions\``,
|
||||||
|
dataType: "BLOB",
|
||||||
|
isVector: true,
|
||||||
|
vectorSize: AppData.EmbeddingDimensions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "chunk_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
sideCar: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "file_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
sideCar: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "scope",
|
||||||
|
dataType: "TEXT",
|
||||||
|
sideCar: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "owner_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
sideCar: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "file_ingest_queue",
|
||||||
|
tableDescription: `Files waiting to have their text chunked and embedded. A queue, so a restart never strands a file and an upload never waits on a model. A finished job is deleted: \`bot_files.ingested_at\` and \`ingest_error\` keep the outcome`,
|
||||||
|
isQueue: {
|
||||||
|
maxAttempts: 3,
|
||||||
|
lockTimeout: 10 * 60 * 1000,
|
||||||
|
backoff: 30_000,
|
||||||
|
deleteOnComplete: true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "file_id",
|
||||||
|
fieldDescription: `The \`bot_files\` row to (re)ingest`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_file_ingest_queue_file_id",
|
||||||
|
indexTableFields: ["file_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "app_settings",
|
||||||
|
tableDescription: `Install-wide settings the app reads that aren't envs: an env's value is handed to bots and computers, which a storage quota must never be`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "key",
|
||||||
|
dataType: "TEXT",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "value",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+182
-5
@@ -40,6 +40,11 @@ export const BunSQLiteTables = [
|
|||||||
"user_presence",
|
"user_presence",
|
||||||
"push_subscriptions",
|
"push_subscriptions",
|
||||||
"envs",
|
"envs",
|
||||||
|
"bot_files",
|
||||||
|
"bot_file_chunks",
|
||||||
|
"bot_file_vectors",
|
||||||
|
"file_ingest_queue",
|
||||||
|
"app_settings",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_USERS = {
|
export type BUN_SQLITE_COBBLE_USERS = {
|
||||||
@@ -156,7 +161,7 @@ export type BUN_SQLITE_COBBLE_MEDIA_PARADIGMS = {
|
|||||||
*/
|
*/
|
||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
media_id?: number | "";
|
media_id?: number | "";
|
||||||
media_paradigm?: "user-profile-image" | "generic" | "event" | "bot-attachment" | "bot-image" | "workspace-image" | "";
|
media_paradigm?: "user-profile-image" | "generic" | "event" | "bot-attachment" | "bot-image" | "workspace-image" | "bot-file" | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOTS = {
|
export type BUN_SQLITE_COBBLE_BOTS = {
|
||||||
@@ -968,7 +973,7 @@ export type BUN_SQLITE_COBBLE_HARNESS_SETTINGS = {
|
|||||||
* The time when the record was updated. (Unix Timestamp)
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
*/
|
*/
|
||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "";
|
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
|
||||||
/**
|
/**
|
||||||
* harness_models row picked by a super admin. Null picks automatically. Not a foreign key, so removing a provider leaves the setting
|
* harness_models row picked by a super admin. Null picks automatically. Not a foreign key, so removing a provider leaves the setting
|
||||||
*/
|
*/
|
||||||
@@ -1008,7 +1013,7 @@ export type BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS = {
|
|||||||
* The worker bot that overrides the default. The coordinator always uses the defaults
|
* The worker bot that overrides the default. The coordinator always uses the defaults
|
||||||
*/
|
*/
|
||||||
bot_id?: number | "";
|
bot_id?: number | "";
|
||||||
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "";
|
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
|
||||||
/**
|
/**
|
||||||
* harness_models row picked for this bot. Null picks automatically. Not a foreign key, like harness_settings
|
* harness_models row picked for this bot. Null picks automatically. Not a foreign key, like harness_settings
|
||||||
*/
|
*/
|
||||||
@@ -1048,7 +1053,7 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS = {
|
|||||||
* The workspace
|
* The workspace
|
||||||
*/
|
*/
|
||||||
workspace_id?: number | "";
|
workspace_id?: number | "";
|
||||||
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "";
|
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
|
||||||
/**
|
/**
|
||||||
* harness_models row picked for this workspace. Null picks automatically. Not a foreign key, like harness_settings
|
* harness_models row picked for this workspace. Null picks automatically. Not a foreign key, like harness_settings
|
||||||
*/
|
*/
|
||||||
@@ -1418,4 +1423,176 @@ export type BUN_SQLITE_COBBLE_ENVS = {
|
|||||||
expose_to_computers?: 0 | 1 | "";
|
expose_to_computers?: 0 | 1 | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS
|
export type BUN_SQLITE_COBBLE_BOT_FILES = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot that wrote or received it. Goes with the bot
|
||||||
|
*/
|
||||||
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* Which directory it's in: a bot's own or a workspace's shared one
|
||||||
|
*/
|
||||||
|
scope?: "bot" | "workspace" | "";
|
||||||
|
/**
|
||||||
|
* The bot id (scope bot) or workspace id (scope workspace) owning the directory. Not a foreign key, so a row outlives a workspace but its files are cleaned up with it
|
||||||
|
*/
|
||||||
|
owner_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The private `media` row holding the bytes. Not a foreign key: the catalog row can outlive it (a failed cleanup) and is then listed as missing
|
||||||
|
*/
|
||||||
|
media_id?: number | "";
|
||||||
|
/**
|
||||||
|
* Path inside the directory, forward slashes, e.g. reports/q3.md
|
||||||
|
*/
|
||||||
|
path?: string;
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* The folder part of `path`, empty at the top
|
||||||
|
*/
|
||||||
|
folder?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
media_type?: "image" | "video" | "document" | "audio" | "";
|
||||||
|
/**
|
||||||
|
* Bytes on disk
|
||||||
|
*/
|
||||||
|
size?: number | "";
|
||||||
|
/**
|
||||||
|
* What it is for: an upload, something the bot wrote, a generated image or PDF
|
||||||
|
*/
|
||||||
|
purpose?: "upload" | "writing" | "image" | "pdf" | "artifact" | "";
|
||||||
|
/**
|
||||||
|
* How it got here: the user attached it to a message, the bot wrote it, or a tool made it
|
||||||
|
*/
|
||||||
|
source?: "chat-upload" | "bot-write" | "tool" | "";
|
||||||
|
/**
|
||||||
|
* When its text was chunked and embedded. Null: not ingested (yet)
|
||||||
|
*/
|
||||||
|
ingested_at?: number | "";
|
||||||
|
/**
|
||||||
|
* Why the last ingestion failed, when it did
|
||||||
|
*/
|
||||||
|
ingest_error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
file_id?: number | "";
|
||||||
|
scope?: "bot" | "workspace" | "";
|
||||||
|
/**
|
||||||
|
* The bot or workspace the file belongs to, so a search filters without a join
|
||||||
|
*/
|
||||||
|
owner_id?: number | "";
|
||||||
|
chunk_index?: number | "";
|
||||||
|
text?: string;
|
||||||
|
/**
|
||||||
|
* A rough count (characters / 4), for trimming results
|
||||||
|
*/
|
||||||
|
token_estimate?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* Normalized, padded or cut to `AppData.EmbeddingDimensions`
|
||||||
|
*/
|
||||||
|
embedding?: Float32Array<ArrayBuffer> | Buffer<ArrayBuffer> | null;
|
||||||
|
chunk_id?: number | "";
|
||||||
|
file_id?: number | "";
|
||||||
|
scope?: string;
|
||||||
|
owner_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The `bot_files` row to (re)ingest
|
||||||
|
*/
|
||||||
|
file_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The state of the job. (pending | processing | completed | failed)
|
||||||
|
*/
|
||||||
|
status?: string;
|
||||||
|
/**
|
||||||
|
* The number of times the job has been claimed.
|
||||||
|
*/
|
||||||
|
attempts?: number | "";
|
||||||
|
/**
|
||||||
|
* The time from which the job can be claimed. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
available_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the job was claimed. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
locked_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The worker that claimed the job.
|
||||||
|
*/
|
||||||
|
locked_by?: string;
|
||||||
|
/**
|
||||||
|
* The error from the last failed attempt.
|
||||||
|
*/
|
||||||
|
last_error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
||||||
@@ -196,6 +196,7 @@ export default async function processBotJob({
|
|||||||
result: outcome.result,
|
result: outcome.result,
|
||||||
needs_you: outcome.needs_you,
|
needs_you: outcome.needs_you,
|
||||||
silent: outcome.silent,
|
silent: outcome.silent,
|
||||||
|
attachments: outcome.attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
await pushBotNotice({ bot_id, ...completion });
|
await pushBotNotice({ bot_id, ...completion });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import {
|
import {
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
|
BotFilesNote,
|
||||||
ChatReplyNote,
|
ChatReplyNote,
|
||||||
CoordinatorReplyNote,
|
CoordinatorReplyNote,
|
||||||
CoordinatorSoloNote,
|
CoordinatorSoloNote,
|
||||||
@@ -12,6 +13,7 @@ import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
|||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import runTextLoop from "@/src/harness/text/run-text-loop";
|
import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||||
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
||||||
|
import grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools";
|
||||||
import openBotExtensions from "@/src/harness/tools/extensions/open-bot-extensions";
|
import openBotExtensions from "@/src/harness/tools/extensions/open-bot-extensions";
|
||||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||||
import composeComputersNote from "./compose-computers-note";
|
import composeComputersNote from "./compose-computers-note";
|
||||||
@@ -76,6 +78,18 @@ export default async function runBotReply({
|
|||||||
}).catch(() => "");
|
}).catch(() => "");
|
||||||
|
|
||||||
const take_overs: string[] = [];
|
const take_overs: string[] = [];
|
||||||
|
/**
|
||||||
|
* Files the bot wrote and asked to send with its reply.
|
||||||
|
*/
|
||||||
|
const attachments: number[] = [];
|
||||||
|
|
||||||
|
const file_tools = await grabBotFileTools({
|
||||||
|
bot,
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
session_id,
|
||||||
|
onAttach: ({ media_id }) => attachments.push(media_id),
|
||||||
|
});
|
||||||
|
|
||||||
const tools = await grabBotChatTools({
|
const tools = await grabBotChatTools({
|
||||||
bot,
|
bot,
|
||||||
@@ -111,6 +125,7 @@ export default async function runBotReply({
|
|||||||
prompt,
|
prompt,
|
||||||
carry_over,
|
carry_over,
|
||||||
ChatReplyNote,
|
ChatReplyNote,
|
||||||
|
BotFilesNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
composeComputersNote({
|
composeComputersNote({
|
||||||
@@ -132,7 +147,7 @@ export default async function runBotReply({
|
|||||||
user_id,
|
user_id,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
tools: [...tools, ...extensions.tools],
|
tools: [...tools, ...file_tools, ...extensions.tools],
|
||||||
max_steps: AppData.BotReplyMaxToolSteps,
|
max_steps: AppData.BotReplyMaxToolSteps,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
@@ -140,7 +155,10 @@ export default async function runBotReply({
|
|||||||
onStep: report,
|
onStep: report,
|
||||||
});
|
});
|
||||||
|
|
||||||
return withTakeOverRefs({ text: result.text, refs: take_overs });
|
return {
|
||||||
|
...withTakeOverRefs({ text: result.text, refs: take_overs }),
|
||||||
|
attachments: attachments.length ? attachments : undefined,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await extensions.close();
|
await extensions.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import { ActionCheckNote, ScheduledRunNote, TaskRunNote, WorkspaceLearningNote } from "@/src/data/harness-prompts";
|
import {
|
||||||
|
ActionCheckNote,
|
||||||
|
BotFilesNote,
|
||||||
|
ScheduledRunNote,
|
||||||
|
TaskRunNote,
|
||||||
|
WorkspaceLearningNote,
|
||||||
|
} from "@/src/data/harness-prompts";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
@@ -11,6 +17,7 @@ import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
|
|||||||
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
|
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
|
||||||
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
|
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
|
||||||
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
|
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
|
||||||
|
import grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools";
|
||||||
import reportRunFailedTool from "@/src/harness/tools/bot/report-run-failed-tool";
|
import reportRunFailedTool from "@/src/harness/tools/bot/report-run-failed-tool";
|
||||||
import computerRef from "@/src/utils/computer-ref";
|
import computerRef from "@/src/utils/computer-ref";
|
||||||
import composeJobBrief from "./compose-job-brief";
|
import composeJobBrief from "./compose-job-brief";
|
||||||
@@ -81,6 +88,10 @@ export default async function runBotTask({
|
|||||||
signal,
|
signal,
|
||||||
}).catch(() => "");
|
}).catch(() => "");
|
||||||
const take_overs: string[] = [];
|
const take_overs: string[] = [];
|
||||||
|
/**
|
||||||
|
* Files the bot wrote and asked to send with its report.
|
||||||
|
*/
|
||||||
|
const attachments: number[] = [];
|
||||||
const scheduled = Boolean(params.schedule_id);
|
const scheduled = Boolean(params.schedule_id);
|
||||||
let failure = null as string | null;
|
let failure = null as string | null;
|
||||||
|
|
||||||
@@ -120,6 +131,13 @@ export default async function runBotTask({
|
|||||||
: []),
|
: []),
|
||||||
...(scheduled ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []),
|
...(scheduled ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []),
|
||||||
...(computer?.tools || []),
|
...(computer?.tools || []),
|
||||||
|
...(await grabBotFileTools({
|
||||||
|
bot,
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
session_id,
|
||||||
|
onAttach: ({ media_id }) => attachments.push(media_id),
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
extensions = await openBotExtensions({
|
extensions = await openBotExtensions({
|
||||||
@@ -136,6 +154,7 @@ export default async function runBotTask({
|
|||||||
carry_over,
|
carry_over,
|
||||||
TaskRunNote,
|
TaskRunNote,
|
||||||
scheduled ? ScheduledRunNote : "",
|
scheduled ? ScheduledRunNote : "",
|
||||||
|
BotFilesNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
bot.kind == "coordinator"
|
bot.kind == "coordinator"
|
||||||
@@ -180,10 +199,13 @@ export default async function runBotTask({
|
|||||||
throw new ScheduledRunFailedError(computer ? computer.redact(failure) : failure);
|
throw new ScheduledRunFailedError(computer ? computer.redact(failure) : failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
return withTakeOverRefs({
|
return {
|
||||||
text: computer ? computer.redact(result.text) : result.text,
|
...withTakeOverRefs({
|
||||||
refs: take_overs,
|
text: computer ? computer.redact(result.text) : result.text,
|
||||||
});
|
refs: take_overs,
|
||||||
|
}),
|
||||||
|
attachments: attachments.length ? attachments : undefined,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await extensions?.close();
|
await extensions?.close();
|
||||||
await computer?.close();
|
await computer?.close();
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export type BotJobOutcome = {
|
|||||||
* Finish without posting `result` (a reply with nothing left to answer).
|
* Finish without posting `result` (a reply with nothing left to answer).
|
||||||
*/
|
*/
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
|
/**
|
||||||
|
* Files the bot wrote and asked to send with its message (`media`
|
||||||
|
* ids), attached to the result message.
|
||||||
|
*/
|
||||||
|
attachments?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BotJobRunner = (context: BotJobContext) => Promise<BotJobOutcome>;
|
export type BotJobRunner = (context: BotJobContext) => Promise<BotJobOutcome>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cronSyncHarnessModels from "./sync-harness-models";
|
|||||||
import syncComputers from "./sync-computers";
|
import syncComputers from "./sync-computers";
|
||||||
import syncMcpServers from "./sync-mcp-servers";
|
import syncMcpServers from "./sync-mcp-servers";
|
||||||
import runCronJobs from "./run-cron-jobs";
|
import runCronJobs from "./run-cron-jobs";
|
||||||
|
import ingestPendingFiles from "./ingest-pending-files";
|
||||||
import registerCronJob from "./register-cron-job";
|
import registerCronJob from "./register-cron-job";
|
||||||
|
|
||||||
export default function cron() {
|
export default function cron() {
|
||||||
@@ -54,4 +55,11 @@ export default function cron() {
|
|||||||
run_on_start: true,
|
run_on_start: true,
|
||||||
quiet: true,
|
quiet: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerCronJob({
|
||||||
|
title: "ingest-pending-files",
|
||||||
|
schedule: "*/10 * * * *",
|
||||||
|
task: ingestPendingFiles,
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues any bot file that never got ingested (an upload that happened
|
||||||
|
* while the worker was down, a restart mid-ingest), so nothing is left
|
||||||
|
* unsearchable. Runs every ten minutes.
|
||||||
|
*/
|
||||||
|
export default async function ingestPendingFiles() {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT f.id FROM bot_files f
|
||||||
|
WHERE f.ingested_at IS NULL
|
||||||
|
AND f.ingest_error IS NULL
|
||||||
|
AND f.created_at > ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM file_ingest_queue q
|
||||||
|
WHERE q.file_id = f.id AND q.status IN ('pending', 'processing')
|
||||||
|
)
|
||||||
|
LIMIT 25`,
|
||||||
|
values: [Date.now() - 24 * 60 * 60 * 1000],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const file of res.payload || []) {
|
||||||
|
if (file.id) await enqueueFileIngest({ file_id: file.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.payload?.length || 0;
|
||||||
|
}
|
||||||
@@ -373,6 +373,65 @@ export const AppData = {
|
|||||||
* Most of a bot message read aloud; the rest is cut.
|
* Most of a bot message read aloud; the rest is cut.
|
||||||
*/
|
*/
|
||||||
SpeechMaxChars: 4000,
|
SpeechMaxChars: 4000,
|
||||||
|
/**
|
||||||
|
* Bot files: a bot's own directory and a workspace's shared one live
|
||||||
|
* under `DATA_DIR` (`DATA_DIR/bots/<id>`, `DATA_DIR/workspaces/<id>`),
|
||||||
|
* on the data volume, never on a computer.
|
||||||
|
*/
|
||||||
|
BotDataDir: "bots",
|
||||||
|
WorkspaceDataDir: "workspaces",
|
||||||
|
/**
|
||||||
|
* The per-owner quota when the General settings page has none saved.
|
||||||
|
*/
|
||||||
|
BotFileQuotaMb: 1024,
|
||||||
|
/**
|
||||||
|
* One file a bot writes in one call, and the most of a file a read
|
||||||
|
* hands back at once (the rest comes with an offset).
|
||||||
|
*/
|
||||||
|
BotFileWriteMaxChars: 200_000,
|
||||||
|
BotFileReadMaxChars: 40_000,
|
||||||
|
BotFileListMaxItems: 200,
|
||||||
|
BotFileNameMaxChars: 120,
|
||||||
|
BotFilePathMaxChars: 400,
|
||||||
|
/**
|
||||||
|
* Most folders deep a path may go, so nothing buries a file.
|
||||||
|
*/
|
||||||
|
BotFilePathMaxDepth: 8,
|
||||||
|
/**
|
||||||
|
* The files modal and the bot's file list: one page of them.
|
||||||
|
*/
|
||||||
|
BotFilesPageSize: 50,
|
||||||
|
/**
|
||||||
|
* Ingesting a bot file: how much text one chunk holds, how much of the
|
||||||
|
* previous chunk it repeats, and the largest file it reads at all.
|
||||||
|
*/
|
||||||
|
ChunkTargetChars: 1200,
|
||||||
|
ChunkOverlapChars: 200,
|
||||||
|
IngestMaxBytes: 20 * 1024 * 1024,
|
||||||
|
/**
|
||||||
|
* The fixed width of every vector, whatever the embedding model
|
||||||
|
* returns: shorter vectors are padded with zeros and longer ones cut,
|
||||||
|
* then all are normalized. Changing it needs
|
||||||
|
* `bunx bun-sqlite schema --vector` and a full re-ingest.
|
||||||
|
*/
|
||||||
|
EmbeddingDimensions: 1536,
|
||||||
|
/**
|
||||||
|
* Texts sent to the embedding model per call, and per run of the
|
||||||
|
* ingestion worker.
|
||||||
|
*/
|
||||||
|
EmbeddingBatchSize: 32,
|
||||||
|
EmbeddingMaxInputChars: 8000,
|
||||||
|
/**
|
||||||
|
* How many chunks a file search returns, and how many more it asks
|
||||||
|
* the vector index for before filtering to the run's scopes.
|
||||||
|
*/
|
||||||
|
RagTopChunks: 8,
|
||||||
|
RagOverFetch: 4,
|
||||||
|
RagMaxK: 60,
|
||||||
|
FileIngestPollMs: 5000,
|
||||||
|
FileIngestConcurrency: 2,
|
||||||
|
FileIngestWorker: "ingest",
|
||||||
|
FileIngestMaxAttempts: 2,
|
||||||
/**
|
/**
|
||||||
* What a speech model reads when a voice is tried in the model settings.
|
* What a speech model reads when a voice is tried in the model settings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -31,11 +31,26 @@ 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.
|
- 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.
|
- 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.
|
- 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 ref, e.g. [file:12], so the user can open it.
|
||||||
|
|
||||||
## Keep the work moving
|
## Keep the work moving
|
||||||
|
|
||||||
Be proactive. Follow where the conversation is heading and, once you've answered, offer the next step: one concrete suggestion or a short question about what to do next, e.g. "Want me to draft the reply as well?" or "Should I check the other two accounts the same way?". Base it on what the user is trying to get done, not on generic options. Offer one step, not a menu. Skip it when you've already asked the user something in this reply, when you've just started a task (its result will suggest what's next), or when the user is clearly wrapping up.`;
|
Be proactive. Follow where the conversation is heading and, once you've answered, offer the next step: one concrete suggestion or a short question about what to do next, e.g. "Want me to draft the reply as well?" or "Should I check the other two accounts the same way?". Base it on what the user is trying to get done, not on generic options. Offer one step, not a menu. Skip it when you've already asked the user something in this reply, when you've just started a task (its result will suggest what's next), or when the user is clearly wrapping up.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to every run, chat replies and tasks alike: where its files live,
|
||||||
|
* that they persist, and how to search them.
|
||||||
|
*/
|
||||||
|
export const BotFilesNote = `# Your files
|
||||||
|
|
||||||
|
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
|
||||||
|
|
||||||
|
- list_files shows what's in it; read_file opens one (by its [file:ID] ref or path); write_file saves a text file, make_pdf a PDF, generate_image an image. Move and delete with move_file and delete_file when something is out of date, so the folder stays useful.
|
||||||
|
- Text files are chunked and searchable as soon as they're saved. search_files finds the passages that answer a question, by meaning when an embedding model is set and by words otherwise. Search your files before saying you don't know something they may hold, and when the user asks about a document they gave you.
|
||||||
|
- Uploaded files land in the same folder, so what the user sent you earlier is searchable later too.
|
||||||
|
- When you name a file, write its [file:ID] ref: the user sees a link to it.
|
||||||
|
- A file you only described is not saved. Save it first, then say it's saved.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to every run, chat replies and tasks alike: check before you
|
* Added to every run, chat replies and tasks alike: check before you
|
||||||
* create, and check after you act.
|
* create, and check after you act.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Where a bot file lives: a bot's own directory (`DATA_DIR/bots/<id>`) or
|
||||||
|
* a workspace's shared one (`DATA_DIR/workspaces/<id>`), which the bots
|
||||||
|
* working there and the coordinator reach.
|
||||||
|
*/
|
||||||
|
export const BotFileScopes = [
|
||||||
|
{
|
||||||
|
title: "Bot",
|
||||||
|
value: "bot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Workspace",
|
||||||
|
value: "workspace",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BotFileScope = (typeof BotFileScopes)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a file is for. Only the display and the icon use it; the bot picks
|
||||||
|
* it when it writes one.
|
||||||
|
*/
|
||||||
|
export const BotFilePurposes = [
|
||||||
|
{
|
||||||
|
title: "Upload",
|
||||||
|
value: "upload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Writing",
|
||||||
|
value: "writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
value: "image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PDF",
|
||||||
|
value: "pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Artifact",
|
||||||
|
value: "artifact",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BotFilePurpose = (typeof BotFilePurposes)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a file got into the bot's storage.
|
||||||
|
*/
|
||||||
|
export const BotFileSources = [
|
||||||
|
{
|
||||||
|
title: "Chat upload",
|
||||||
|
value: "chat-upload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bot write",
|
||||||
|
value: "bot-write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tool",
|
||||||
|
value: "tool",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BotFileSource = (typeof BotFileSources)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `app_settings` key holding the per-owner storage quota, in MB.
|
||||||
|
*/
|
||||||
|
export const BotFileQuotaSettingKey = "bot_files_quota_mb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top folder chat uploads land in, under the conversation's scope.
|
||||||
|
*/
|
||||||
|
export const BotUploadFolder = "uploads";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extensions a bot's text extraction reads without a model: documents
|
||||||
|
* that are already text once unpacked.
|
||||||
|
*/
|
||||||
|
export const BotTextFileExtensions = [
|
||||||
|
"txt",
|
||||||
|
"md",
|
||||||
|
"markdown",
|
||||||
|
"csv",
|
||||||
|
"json",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"xml",
|
||||||
|
"yml",
|
||||||
|
"yaml",
|
||||||
|
"log",
|
||||||
|
"tsv",
|
||||||
|
"pdf",
|
||||||
|
"docx",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a file's extension says it is, for the storage row and the icon.
|
||||||
|
*/
|
||||||
|
export const BotImageFileExtensions = ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"];
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
* The harnesses: what a model is used for. Each harness has one model
|
* The harnesses: what a model is used for. Each harness has one model
|
||||||
* setting (`harness_settings`). Text is the brain and can't be turned off;
|
* setting (`harness_settings`). Text is the brain and can't be turned off;
|
||||||
* the others are helpers it calls on. `automatic` ones pick a free model
|
* the others are helpers it calls on. `automatic` ones pick a free model
|
||||||
* themselves when none is picked; the rest (transcription, speech) are
|
* themselves when none is picked; the rest (transcription, speech,
|
||||||
* not available until one is, since free picks there rarely work.
|
* embeddings) are not available until one is, since free picks there
|
||||||
|
* rarely work.
|
||||||
*/
|
*/
|
||||||
export const Harnesses = [
|
export const Harnesses = [
|
||||||
{
|
{
|
||||||
@@ -41,6 +42,13 @@ export const Harnesses = [
|
|||||||
description: `Makes images when a bot is asked for one.`,
|
description: `Makes images when a bot is asked for one.`,
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Embeddings",
|
||||||
|
value: "embedding",
|
||||||
|
automatic: false,
|
||||||
|
description: `Turns the files bots save into vectors, so they can search them. Without one, searching falls back to plain text matching.`,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
||||||
|
|||||||
@@ -42,4 +42,8 @@ export const MediaParadigms = [
|
|||||||
title: "Workspace Image",
|
title: "Workspace Image",
|
||||||
value: "workspace-image",
|
value: "workspace-image",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Bot File",
|
||||||
|
value: "bot-file",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE } from "@/db/types/db";
|
||||||
|
import wakeFileIngest from "./wake-file-ingest";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
file_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a file to be chunked and embedded, unless it's already waiting.
|
||||||
|
* Never fails the caller: a file that can't be queued is picked up by the
|
||||||
|
* `ingest-pending-files` cron instead.
|
||||||
|
*/
|
||||||
|
export default async function enqueueFileIngest({ file_id }: Params) {
|
||||||
|
try {
|
||||||
|
const open = await BunSQLite.sql<BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE>({
|
||||||
|
sql: `SELECT id FROM file_ingest_queue WHERE file_id = ? AND status IN ('pending', 'processing') LIMIT 1`,
|
||||||
|
values: [file_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (open.payload?.[0]?.id) {
|
||||||
|
wakeFileIngest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.queue.add({
|
||||||
|
table: "file_ingest_queue",
|
||||||
|
data: [{ file_id: Number(file_id) }],
|
||||||
|
});
|
||||||
|
|
||||||
|
wakeFileIngest();
|
||||||
|
} catch {
|
||||||
|
/**
|
||||||
|
* The cron sweeps it up.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import bunext from "@moduletrace/bunext";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE } from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
import processIngestJob from "./process-ingest-job";
|
||||||
|
import { setFileIngestWaker } from "./wake-file-ingest";
|
||||||
|
|
||||||
|
const worker = AppData.FileIngestWorker;
|
||||||
|
let running = 0;
|
||||||
|
let polling = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the file ingestion worker: polls every `AppData.FileIngestPollMs`
|
||||||
|
* for `file_ingest_queue` jobs and runs up to
|
||||||
|
* `AppData.FileIngestConcurrency` at once (chunking and embedding a bot's
|
||||||
|
* file). Gated in production to the `MAIN_SERVER` container, like cron and
|
||||||
|
* the bots queue.
|
||||||
|
*/
|
||||||
|
export default async function fileIngest() {
|
||||||
|
if (process.env.NODE_ENV == "production" && !process.env.MAIN_SERVER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bunext.bunextLog.watch(`Running File Ingest Queue ...`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A restart's cut-off jobs go back to waiting (their lock timeout
|
||||||
|
* would, but this is immediate).
|
||||||
|
*/
|
||||||
|
await BunSQLite.queue.release_stale({ table: "file_ingest_queue" }).catch((error) =>
|
||||||
|
logError({ title: "File ingest couldn't release stale jobs", error }).catch(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setFileIngestWaker({ waker: wake });
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wake() {
|
||||||
|
if (polling) return;
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
polling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const free = AppData.FileIngestConcurrency - running;
|
||||||
|
|
||||||
|
if (free > 0) {
|
||||||
|
const { payload } = await BunSQLite.queue.claim<BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE>({
|
||||||
|
table: "file_ingest_queue",
|
||||||
|
worker,
|
||||||
|
limit: free,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const job of payload || []) {
|
||||||
|
running++;
|
||||||
|
|
||||||
|
processIngestJob({ job })
|
||||||
|
.catch((error) =>
|
||||||
|
logError({
|
||||||
|
title: `File ingest job #${job.id} crashed`,
|
||||||
|
error,
|
||||||
|
}).catch(() => {}),
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
running--;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await logError({ title: "File ingest poll failed", error }).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(poll, AppData.FileIngestPollMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE } from "@/db/types/db";
|
||||||
|
import ingestBotFile from "@/src/functions/backend/db/bot-files/ingest-bot-file";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
job: BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingests one queued file and completes (or fails) its job. A file that
|
||||||
|
* was deleted meanwhile is simply done.
|
||||||
|
*/
|
||||||
|
export default async function processIngestJob({ job }: Params) {
|
||||||
|
if (!job.id || !job.file_id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ingestBotFile({ file_id: job.file_id });
|
||||||
|
|
||||||
|
await BunSQLite.queue.complete({ table: "file_ingest_queue", id: job.id });
|
||||||
|
|
||||||
|
if (result.status == "ingested") {
|
||||||
|
BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_files SET ingest_error = NULL WHERE id = ?`,
|
||||||
|
values: [job.file_id],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
await BunSQLite.queue
|
||||||
|
.fail({ table: "file_ingest_queue", id: job.id, error: message.slice(0, 500) })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
await logError({
|
||||||
|
title: `Bot file #${job.file_id} couldn't be ingested`,
|
||||||
|
error,
|
||||||
|
metadata: { file_id: job.file_id },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
let wake: (() => void) | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the running ingest worker register how to poll at once, kept apart
|
||||||
|
* from the handler so an upload can wake it without bundling it.
|
||||||
|
*/
|
||||||
|
export function setFileIngestWaker({ waker }: { waker: () => void }) {
|
||||||
|
wake = waker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls now instead of at the next tick, so a fresh file is readable
|
||||||
|
* moments after it lands. Does nothing where the worker doesn't run.
|
||||||
|
*/
|
||||||
|
export default function wakeFileIngest() {
|
||||||
|
wake?.();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import grabAppSettings from "./grab-app-settings";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One install-wide setting, or undefined when it was never saved.
|
||||||
|
*/
|
||||||
|
export default async function grabAppSetting({ key }: Params) {
|
||||||
|
return (await grabAppSettings()).get(key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
||||||
|
|
||||||
|
let cache: Map<string, string> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
||||||
|
* `saveAppSetting` and `deleteAppSetting` clear it.
|
||||||
|
*/
|
||||||
|
export default async function grabAppSettings() {
|
||||||
|
if (cache) return cache;
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
||||||
|
sql: `SELECT key, value FROM app_settings`,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const row of res.payload || []) {
|
||||||
|
if (row.key) map.set(row.key, row.value || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = map;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the cache, so the next read sees the database.
|
||||||
|
*/
|
||||||
|
export function clearAppSettingsCache() {
|
||||||
|
cache = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { BotFileQuotaSettingKey } from "@/src/dict/bot-file-dict";
|
||||||
|
import { grabBotFileQuotaMb } from "../bot-files/grab-bot-file-quota-bytes";
|
||||||
|
import { grabStorageUsageByOwner, grabTotalStorageUsage } from "../bot-files/grab-storage-usage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The General settings' bot file storage: the quota each folder gets, and
|
||||||
|
* what is used right now.
|
||||||
|
*/
|
||||||
|
export default async function grabStorageQuotaOverview() {
|
||||||
|
const [quota_mb, total, by_owner] = await Promise.all([
|
||||||
|
grabBotFileQuotaMb(),
|
||||||
|
grabTotalStorageUsage(),
|
||||||
|
grabStorageUsageByOwner(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setting_key: BotFileQuotaSettingKey,
|
||||||
|
quota_mb,
|
||||||
|
total_bytes: total.bytes,
|
||||||
|
total_files: total.files,
|
||||||
|
folders: by_owner.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorageQuotaOverview = Awaited<ReturnType<typeof grabStorageQuotaOverview>>;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_APP_SETTINGS, BunSQLiteTables } from "@/db/types/db";
|
||||||
|
import { clearAppSettingsCache } from "./grab-app-settings";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Null or empty removes it, so the code's default applies again.
|
||||||
|
*/
|
||||||
|
value?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves (or removes) one install-wide setting and clears the cache, so the
|
||||||
|
* next read sees it.
|
||||||
|
*/
|
||||||
|
export default async function saveAppSetting({ key, value }: Params) {
|
||||||
|
const existing = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
||||||
|
sql: `SELECT id FROM app_settings WHERE key = ? LIMIT 1`,
|
||||||
|
values: [key],
|
||||||
|
}).then((res) => res.payload?.[0]);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM app_settings WHERE key = ?`,
|
||||||
|
values: [key],
|
||||||
|
});
|
||||||
|
clearAppSettingsCache();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing?.id) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE app_settings SET value = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [value, Date.now(), existing.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await BunSQLite.insert<BUN_SQLITE_COBBLE_APP_SETTINGS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "app_settings",
|
||||||
|
data: [{ key, value }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAppSettingsCache();
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { BotFileScope } from "@/src/dict/bot-file-dict";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabBotFileQuotaBytes from "./grab-bot-file-quota-bytes";
|
||||||
|
import grabStorageUsage from "./grab-storage-usage";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scope: BotFileScope;
|
||||||
|
owner_id: number | string;
|
||||||
|
/**
|
||||||
|
* How many bytes are about to be added. A rewrite of an existing file
|
||||||
|
* passes only the growth.
|
||||||
|
*/
|
||||||
|
incoming_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a write that would push a directory past its quota, naming the
|
||||||
|
* limit and the current usage so the bot can clear space first.
|
||||||
|
*/
|
||||||
|
export default async function assertStorageRoom({
|
||||||
|
scope,
|
||||||
|
owner_id,
|
||||||
|
incoming_bytes,
|
||||||
|
}: Params) {
|
||||||
|
const quota = await grabBotFileQuotaBytes();
|
||||||
|
|
||||||
|
if (incoming_bytes <= 0) return;
|
||||||
|
|
||||||
|
const usage = await grabStorageUsage({ scope, owner_id });
|
||||||
|
|
||||||
|
if (usage.bytes + incoming_bytes <= quota) return;
|
||||||
|
|
||||||
|
throw new AppError(
|
||||||
|
`No room left: ${formatMb({ bytes: usage.bytes })} of ${formatMb({ bytes: quota })} used in ${scope == "workspace" ? "this workspace" : "your files"}. Delete or move something first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A size in MB, for messages: "12.4 MB".
|
||||||
|
*/
|
||||||
|
export function formatMb({ bytes }: { bytes: number }) {
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
|
||||||
|
if (mb >= 10) return `${Math.round(mb)} MB`;
|
||||||
|
|
||||||
|
return `${mb.toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import chunkText from "./chunk-text";
|
||||||
|
|
||||||
|
describe("chunkText", () => {
|
||||||
|
test("returns nothing for empty text", () => {
|
||||||
|
expect(chunkText({ text: " \n\n " })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a short document in one chunk", () => {
|
||||||
|
expect(chunkText({ text: "One paragraph.\n\nAnother one." })).toEqual([
|
||||||
|
"One paragraph.\n\nAnother one.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("splits at paragraph boundaries, never mid-word", () => {
|
||||||
|
const paragraph = "word ".repeat(40).trim();
|
||||||
|
const chunks = chunkText({ text: [1, 2, 3, 4, 5].map(() => paragraph).join("\n\n"), target: 300, overlap: 0 });
|
||||||
|
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
expect(chunk.split(/\s+/).every((word) => word == "word")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("carries the heading into the chunk it starts", () => {
|
||||||
|
const text = "# Report\n\nThe numbers are up.\n\n## Details\n\nEverything is fine.";
|
||||||
|
const chunks = chunkText({ text, target: 120, overlap: 0 });
|
||||||
|
|
||||||
|
expect(chunks[0]).toContain("# Report");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overlaps consecutive chunks of a long paragraph", () => {
|
||||||
|
const text = Array.from({ length: 200 }, (_, i) => `word${i}`).join(" ");
|
||||||
|
const chunks = chunkText({ text, target: 200, overlap: 40 });
|
||||||
|
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
const first_tail = chunks[0]!.split(" ").slice(-2).join(" ");
|
||||||
|
|
||||||
|
expect(chunks[1]!.startsWith(first_tail.split(" ")[0]!) || chunks[1]!.includes(first_tail)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a word longer than the target whole", () => {
|
||||||
|
const chunks = chunkText({ text: "a".repeat(400), target: 100, overlap: 0 });
|
||||||
|
|
||||||
|
expect(chunks.join("")).toContain("a".repeat(400).slice(0, 10));
|
||||||
|
expect(chunks.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapses runs of blank lines", () => {
|
||||||
|
expect(chunkText({ text: "One.\n\n\n\n\nTwo." })).toEqual(["One.\n\nTwo."]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* How much text one chunk holds, in characters.
|
||||||
|
*/
|
||||||
|
target?: number;
|
||||||
|
/**
|
||||||
|
* How much of the previous chunk the next one repeats, so a sentence
|
||||||
|
* cut in two is still whole somewhere.
|
||||||
|
*/
|
||||||
|
overlap?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a document's text into chunks for embedding: paragraphs packed up
|
||||||
|
* to the target size, a heading carried into the chunk that follows it,
|
||||||
|
* and long paragraphs cut at word boundaries with an overlap. Never cuts
|
||||||
|
* mid-word (a word longer than the target is the one exception, since
|
||||||
|
* there's nowhere else to cut).
|
||||||
|
*/
|
||||||
|
export default function chunkText({
|
||||||
|
text,
|
||||||
|
target = AppData.ChunkTargetChars,
|
||||||
|
overlap = AppData.ChunkOverlapChars,
|
||||||
|
}: Params): string[] {
|
||||||
|
const normalized = text.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
|
||||||
|
if (!normalized) return [];
|
||||||
|
|
||||||
|
const blocks = normalized.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
/**
|
||||||
|
* The current chunk's blocks, then (after a flush) only the overlap
|
||||||
|
* carried over: `fresh` says whether anything new joined it, so a
|
||||||
|
* carry on its own is never pushed as a chunk.
|
||||||
|
*/
|
||||||
|
let current: string[] = [];
|
||||||
|
let current_length = 0;
|
||||||
|
let fresh = false;
|
||||||
|
let heading = "";
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
const body = current.join("\n\n").trim();
|
||||||
|
|
||||||
|
if (body && fresh) {
|
||||||
|
chunks.push(heading && !body.startsWith(heading) ? `${heading}\n\n${body}` : body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const carry = overlap > 0 ? tail({ text: body, size: overlap }) : "";
|
||||||
|
|
||||||
|
current = carry ? [carry] : [];
|
||||||
|
current_length = carry.length;
|
||||||
|
fresh = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const is_heading = /^#{1,6}\s/.test(block);
|
||||||
|
|
||||||
|
if (is_heading) {
|
||||||
|
/**
|
||||||
|
* A heading starts a new chunk with it, so a chunk says what it
|
||||||
|
* is about.
|
||||||
|
*/
|
||||||
|
if (current_length > 0) flush();
|
||||||
|
|
||||||
|
heading = block;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.length >= target) {
|
||||||
|
if (current_length > 0) flush();
|
||||||
|
|
||||||
|
for (const piece of splitLongBlock({ block, target, overlap })) {
|
||||||
|
chunks.push(heading ? `${heading}\n\n${piece}` : piece);
|
||||||
|
}
|
||||||
|
|
||||||
|
current = [];
|
||||||
|
current_length = 0;
|
||||||
|
fresh = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current_length + block.length + 2 > target) flush();
|
||||||
|
|
||||||
|
current.push(block);
|
||||||
|
current_length += block.length + 2;
|
||||||
|
fresh = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current_length > 0) flush();
|
||||||
|
|
||||||
|
return chunks.filter((chunk) => chunk.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts a block that is longer than one chunk at word boundaries, keeping
|
||||||
|
* the overlap between pieces.
|
||||||
|
*/
|
||||||
|
function splitLongBlock({
|
||||||
|
block,
|
||||||
|
target,
|
||||||
|
overlap,
|
||||||
|
}: {
|
||||||
|
block: string;
|
||||||
|
target: number;
|
||||||
|
overlap: number;
|
||||||
|
}) {
|
||||||
|
const words = block.split(/\s+/);
|
||||||
|
const pieces: string[] = [];
|
||||||
|
let current: string[] = [];
|
||||||
|
let length = 0;
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
if (length + word.length + 1 > target && current.length) {
|
||||||
|
pieces.push(current.join(" "));
|
||||||
|
const carry = tail({ text: current.join(" "), size: overlap });
|
||||||
|
|
||||||
|
current = carry ? carry.split(" ") : [];
|
||||||
|
length = carry.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!word) continue;
|
||||||
|
|
||||||
|
current.push(word);
|
||||||
|
length += word.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length) pieces.push(current.join(" "));
|
||||||
|
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last `size` characters of a text, starting at a word boundary so the
|
||||||
|
* overlap doesn't begin mid-word.
|
||||||
|
*/
|
||||||
|
function tail({ text, size }: { text: string; size: number }) {
|
||||||
|
if (!text || size <= 0) return "";
|
||||||
|
|
||||||
|
if (text.length <= size) return text;
|
||||||
|
|
||||||
|
const cut = text.slice(text.length - size);
|
||||||
|
const boundary = cut.search(/\s/);
|
||||||
|
|
||||||
|
return boundary == -1 ? cut : cut.slice(boundary).trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
import { canReachScope } from "@/src/utils/storage-access";
|
||||||
|
import grabBotFile from "./grab-bot-file";
|
||||||
|
import recordBotFile from "./record-bot-file";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
file_id: number | string;
|
||||||
|
/**
|
||||||
|
* The bot making the copy (it owns the new row).
|
||||||
|
*/
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Where the copy goes: one of the run's directories.
|
||||||
|
*/
|
||||||
|
scope: StorageScope;
|
||||||
|
/**
|
||||||
|
* Its path there. Defaults to the source's path.
|
||||||
|
*/
|
||||||
|
path?: string | null;
|
||||||
|
user_id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a file into another of the run's directories (the coordinator
|
||||||
|
* publishing a worker's artifact into its own, or into a workspace). The
|
||||||
|
* bytes are copied, so deleting one leaves the other.
|
||||||
|
*/
|
||||||
|
export default async function copyBotFile({
|
||||||
|
scopes,
|
||||||
|
file_id,
|
||||||
|
bot_id,
|
||||||
|
scope,
|
||||||
|
path,
|
||||||
|
user_id,
|
||||||
|
}: Params) {
|
||||||
|
if (!canReachScope({ scopes, scope: scope.scope, owner_id: scope.owner_id })) {
|
||||||
|
throw new AppError(`You can't write to that folder.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await grabBotFile({ scopes, file_id });
|
||||||
|
|
||||||
|
if (!file?.id || !file.media?.media_write_path) {
|
||||||
|
throw new AppError(`That file isn't in your files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const source = await Bun.file(`${DATA_DIR}/${file.media.media_write_path}`).arrayBuffer();
|
||||||
|
|
||||||
|
return await recordBotFile({
|
||||||
|
bot_id,
|
||||||
|
scope: scope.scope,
|
||||||
|
owner_id: scope.owner_id,
|
||||||
|
path: path || file.path || file.name || `copy-${file.id}`,
|
||||||
|
data: new Uint8Array(source),
|
||||||
|
purpose: (file.purpose as any) || "writing",
|
||||||
|
source: "tool",
|
||||||
|
media_type: (file.media_type as any) || undefined,
|
||||||
|
mime_type: file.mime_type || undefined,
|
||||||
|
user_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
import grabBotFile from "./grab-bot-file";
|
||||||
|
import grabStorageDir from "./grab-storage-dir";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
file_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a bot file: the catalog row (its chunks go with it), its bytes
|
||||||
|
* and its `media` row. Refuses anything outside the run's directories.
|
||||||
|
*/
|
||||||
|
export default async function deleteBotFile({ scopes, file_id }: Params) {
|
||||||
|
const file = await grabBotFile({ scopes, file_id });
|
||||||
|
|
||||||
|
if (!file?.id) {
|
||||||
|
throw new AppError(`That file isn't in your files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
|
||||||
|
const absolute = path.join(root, file.path || "");
|
||||||
|
|
||||||
|
if (absolute.startsWith(path.resolve(root))) {
|
||||||
|
rmSync(absolute, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM bot_file_vectors WHERE file_id = ?`, values: [file.id] });
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM bot_files WHERE id = ?`, values: [file.id] });
|
||||||
|
/**
|
||||||
|
* Its pending ingestion would only find nothing left.
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM file_ingest_queue WHERE file_id = ? AND status IN ('pending', 'processing')`,
|
||||||
|
values: [file.id],
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
if (file.media_id) {
|
||||||
|
const media = await BunSQLite.sql<{ media_write_path?: string; media_thumbnail_write_path?: string }>({
|
||||||
|
sql: `SELECT media_write_path, media_thumbnail_write_path FROM media WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file.media_id],
|
||||||
|
}).then((res) => res.payload?.[0]);
|
||||||
|
|
||||||
|
if (media?.media_write_path) {
|
||||||
|
rmSync(path.join(DATA_DIR, media.media_write_path), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media?.media_thumbnail_write_path) {
|
||||||
|
rmSync(path.join(DATA_DIR, media.media_thumbnail_write_path), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM media WHERE id = ?`, values: [file.media_id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return file as BUN_SQLITE_COBBLE_BOT_FILES;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import {
|
||||||
|
BotImageFileExtensions,
|
||||||
|
BotTextFileExtensions,
|
||||||
|
} from "@/src/dict/bot-file-dict";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import fileToText from "../users/media/file-to-text";
|
||||||
|
import { extensionOf } from "./media-type-for-file";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The `media` row's write path (relative to `DATA_DIR`).
|
||||||
|
*/
|
||||||
|
write_path?: string | null;
|
||||||
|
/**
|
||||||
|
* Its text, when a previous upload already extracted it.
|
||||||
|
*/
|
||||||
|
text_content?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
mime_type?: string | null;
|
||||||
|
/**
|
||||||
|
* Read images and audio too, through the media harness. Off by
|
||||||
|
* default: only a bot asking for it pays for that.
|
||||||
|
*/
|
||||||
|
read_media?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file's text, for chunking: documents unpacked locally (PDF, DOCX,
|
||||||
|
* plain text, markdown, CSV, JSON, HTML), images and audio only when
|
||||||
|
* `read_media` is set. Null when the file has no text to read.
|
||||||
|
*/
|
||||||
|
export default async function extractBotFileText({
|
||||||
|
write_path,
|
||||||
|
text_content,
|
||||||
|
name,
|
||||||
|
mime_type,
|
||||||
|
read_media,
|
||||||
|
}: Params): Promise<string | null> {
|
||||||
|
if (text_content?.trim()) return text_content.trim();
|
||||||
|
|
||||||
|
if (!write_path) return null;
|
||||||
|
|
||||||
|
const ext = extensionOf({ name, mime_type });
|
||||||
|
|
||||||
|
if (!ext) return null;
|
||||||
|
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const file_path = `${DATA_DIR}/${write_path}`;
|
||||||
|
|
||||||
|
if ((BotTextFileExtensions as readonly string[]).includes(ext)) {
|
||||||
|
/**
|
||||||
|
* Markdown, CSV and the other plain text types read as they are:
|
||||||
|
* no unpacking needed, and no size limit spent on it.
|
||||||
|
*/
|
||||||
|
if (!["pdf", "docx"].includes(ext)) {
|
||||||
|
const file = Bun.file(file_path);
|
||||||
|
|
||||||
|
if (file.size > AppData.IngestMaxBytes) return null;
|
||||||
|
|
||||||
|
return await file.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Bun.file(file_path);
|
||||||
|
|
||||||
|
if (file.size > AppData.IngestMaxBytes) return null;
|
||||||
|
|
||||||
|
return (await fileToText({ file_path })) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read_media) {
|
||||||
|
const file = Bun.file(file_path);
|
||||||
|
|
||||||
|
if (file.size > AppData.IngestMaxBytes) return null;
|
||||||
|
|
||||||
|
const { default: readMedia } = await import("@/src/harness/media/read-media");
|
||||||
|
const media_type = (BotImageFileExtensions as readonly string[]).includes(ext)
|
||||||
|
? `image/${ext == "jpg" ? "jpeg" : ext}`
|
||||||
|
: mime_type || "audio/mpeg";
|
||||||
|
|
||||||
|
return await readMedia({
|
||||||
|
data: new Uint8Array(await file.arrayBuffer()),
|
||||||
|
media_type,
|
||||||
|
name: name || undefined,
|
||||||
|
})
|
||||||
|
.then((text) => text?.trim() || null)
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_BOT_FILES,
|
||||||
|
BUN_SQLITE_COBBLE_MEDIA,
|
||||||
|
} from "@/db/types/db";
|
||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
|
import grabUserWorkspace from "../workspaces/grab-user-workspace";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
media_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot file's bytes (its private `media` row) for a user who reaches the
|
||||||
|
* directory it's in: the bot's own chat (or any worker, for super admins
|
||||||
|
* and admins; the coordinator only for the super admin), or the workspace
|
||||||
|
* it's shared in. Files belong to the install, like the crew, so whoever
|
||||||
|
* sees the bot sees what it saved.
|
||||||
|
*/
|
||||||
|
export default async function grabBotFileMedia({
|
||||||
|
media_id,
|
||||||
|
user_id,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_MEDIA | null> {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT * FROM bot_files WHERE media_id = ? LIMIT 1`,
|
||||||
|
values: [media_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = res.payload?.[0];
|
||||||
|
|
||||||
|
if (!file?.id) return null;
|
||||||
|
|
||||||
|
const role = await grabCrewRole({ user_id });
|
||||||
|
const reaches =
|
||||||
|
file.scope == "workspace"
|
||||||
|
? Boolean(await grabUserWorkspace({ workspace_id: file.owner_id!, user_id }))
|
||||||
|
: role == "super_admin"
|
||||||
|
? true
|
||||||
|
: Boolean(await grabUserBot({ bot_id: file.owner_id!, user_id }));
|
||||||
|
|
||||||
|
if (!reaches) return null;
|
||||||
|
|
||||||
|
const media = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
|
||||||
|
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
|
||||||
|
values: [media_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return media.payload?.[0] || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { BotFileQuotaSettingKey } from "@/src/dict/bot-file-dict";
|
||||||
|
import grabAppSetting from "../app-settings/grab-app-setting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storage limit one directory may hold, from the General settings
|
||||||
|
* (`bot_files_quota_mb`), else `AppData.BotFileQuotaMb`. A workspace's
|
||||||
|
* shared directory gets the same limit as a bot's own.
|
||||||
|
*/
|
||||||
|
export default async function grabBotFileQuotaBytes() {
|
||||||
|
const saved = Number(await grabAppSetting({ key: BotFileQuotaSettingKey }));
|
||||||
|
const mb = Number.isFinite(saved) && saved > 0 ? saved : AppData.BotFileQuotaMb;
|
||||||
|
|
||||||
|
return Math.round(mb * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same limit in MB, for the settings page.
|
||||||
|
*/
|
||||||
|
export async function grabBotFileQuotaMb() {
|
||||||
|
const saved = Number(await grabAppSetting({ key: BotFileQuotaSettingKey }));
|
||||||
|
|
||||||
|
return Number.isFinite(saved) && saved > 0 ? saved : AppData.BotFileQuotaMb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_BOT_FILES,
|
||||||
|
BUN_SQLITE_COBBLE_MEDIA,
|
||||||
|
} from "@/db/types/db";
|
||||||
|
import { canReachScope, type StorageScope } from "@/src/utils/storage-access";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The directories the run may touch (`grabStorageScopes`).
|
||||||
|
*/
|
||||||
|
scopes: StorageScope[];
|
||||||
|
/**
|
||||||
|
* The catalog row's id.
|
||||||
|
*/
|
||||||
|
file_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Or its path, with the directory it's in.
|
||||||
|
*/
|
||||||
|
scope?: string | null;
|
||||||
|
owner_id?: number | string | null;
|
||||||
|
path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotFileWithMedia = BUN_SQLITE_COBBLE_BOT_FILES & {
|
||||||
|
media?: BUN_SQLITE_COBBLE_MEDIA | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bot file, only if it's in one of the run's directories, with its
|
||||||
|
* `media` row when that still exists.
|
||||||
|
*/
|
||||||
|
export default async function grabBotFile({
|
||||||
|
scopes,
|
||||||
|
file_id,
|
||||||
|
scope,
|
||||||
|
owner_id,
|
||||||
|
path,
|
||||||
|
}: Params): Promise<BotFileWithMedia | null> {
|
||||||
|
if (!file_id && !(scope && owner_id != null && path)) return null;
|
||||||
|
|
||||||
|
const res = file_id
|
||||||
|
? await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT * FROM bot_files WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file_id],
|
||||||
|
})
|
||||||
|
: await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT * FROM bot_files WHERE scope = ? AND owner_id = ? AND path = ? LIMIT 1`,
|
||||||
|
values: [String(scope), Number(owner_id), String(path)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = res.payload?.[0];
|
||||||
|
|
||||||
|
if (!file?.id) return null;
|
||||||
|
|
||||||
|
if (!canReachScope({ scopes, scope: file.scope as any, owner_id: file.owner_id! })) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = file.media_id
|
||||||
|
? await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
|
||||||
|
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file.media_id],
|
||||||
|
}).then((r) => r.payload?.[0] || null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { ...file, media };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { BotFileScopesPayload } from "@/src/types";
|
||||||
|
import grabMediaURL from "../../media/grab-media-url";
|
||||||
|
import grabBotFileQuotaBytes, { grabBotFileQuotaMb } from "./grab-bot-file-quota-bytes";
|
||||||
|
import grabStorageUsage from "./grab-storage-usage";
|
||||||
|
import listBotFiles from "./list-bot-files";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number;
|
||||||
|
/**
|
||||||
|
* The workspace the conversation is in: its shared folder, else the
|
||||||
|
* bot's own.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation's files, for its page props: the folder it works in
|
||||||
|
* (a workspace's shared one, else the bot's own), its latest
|
||||||
|
* `AppData.BotFilesPageSize` of them, and the usage against the quota.
|
||||||
|
* The full list (folders, search, older files) loads in the files modal.
|
||||||
|
*/
|
||||||
|
export default async function grabConversationFiles({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BotFileScopesPayload> {
|
||||||
|
const scope = workspace_id
|
||||||
|
? ({ scope: "workspace", owner_id: Number(workspace_id) } as const)
|
||||||
|
: ({ scope: "bot", owner_id: Number(bot_id) } as const);
|
||||||
|
|
||||||
|
const [files, usage, quota_bytes, quota_mb] = await Promise.all([
|
||||||
|
listBotFiles({ scopes: [scope], limit: AppData.BotFilesPageSize }),
|
||||||
|
grabStorageUsage(scope),
|
||||||
|
grabBotFileQuotaBytes(),
|
||||||
|
grabBotFileQuotaMb(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: files.map((file) => {
|
||||||
|
const { media_url } = grabMediaURL({ media: { id: file.media_id } as any });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(file.id),
|
||||||
|
name: file.name || "",
|
||||||
|
path: file.path || "",
|
||||||
|
size: Number(file.size) || 0,
|
||||||
|
media_type: String(file.media_type || "document"),
|
||||||
|
purpose: String(file.purpose || "writing"),
|
||||||
|
scope: String(file.scope || "bot"),
|
||||||
|
owner_id: Number(file.owner_id) || 0,
|
||||||
|
ingested_at: file.ingested_at ? Number(file.ingested_at) : undefined,
|
||||||
|
ingest_error: file.ingest_error || undefined,
|
||||||
|
created_at: Number(file.created_at) || 0,
|
||||||
|
url: media_url || "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
folders: [],
|
||||||
|
usage,
|
||||||
|
quota_bytes,
|
||||||
|
quota_mb,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { BotFileScope } from "@/src/dict/bot-file-dict";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scope: BotFileScope;
|
||||||
|
/**
|
||||||
|
* The bot id (scope bot) or workspace id (scope workspace).
|
||||||
|
*/
|
||||||
|
owner_id: number | string;
|
||||||
|
/**
|
||||||
|
* Create it when it's missing (the directories are made on demand, so
|
||||||
|
* an install from before this has no backfill to do).
|
||||||
|
*/
|
||||||
|
create?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A storage directory on the data volume: `DATA_DIR/bots/<id>` for a bot,
|
||||||
|
* `DATA_DIR/workspaces/<id>` for a workspace. Files never live on a
|
||||||
|
* computer, so they survive one being rebuilt.
|
||||||
|
*/
|
||||||
|
export default function grabStorageDir({ scope, owner_id, create = true }: Params) {
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const folder = scope == "workspace" ? AppData.WorkspaceDataDir : AppData.BotDataDir;
|
||||||
|
const dir = path.join(DATA_DIR, folder, String(owner_id));
|
||||||
|
|
||||||
|
if (create) mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BotFileScope } from "@/src/dict/bot-file-dict";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scope: BotFileScope;
|
||||||
|
owner_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageRow = {
|
||||||
|
total: number | null;
|
||||||
|
files: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many bytes (and files) a directory holds.
|
||||||
|
*/
|
||||||
|
export default async function grabStorageUsage({ scope, owner_id }: Params) {
|
||||||
|
const res = await BunSQLite.sql<UsageRow>({
|
||||||
|
sql: `SELECT COALESCE(SUM(size), 0) AS total, COUNT(*) AS files FROM bot_files WHERE scope = ? AND owner_id = ?`,
|
||||||
|
values: [scope, owner_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = res.payload?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: Number(row?.total) || 0,
|
||||||
|
files: Number(row?.files) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every directory's usage at once, for the settings page.
|
||||||
|
*/
|
||||||
|
export async function grabStorageUsageByOwner() {
|
||||||
|
const res = await BunSQLite.sql<{ scope: string; owner_id: number; total: number; files: number }>({
|
||||||
|
sql: `SELECT scope, owner_id, SUM(size) AS total, COUNT(*) AS files FROM bot_files GROUP BY scope, owner_id`,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (res.payload || []).map((row) => ({
|
||||||
|
scope: row.scope as BotFileScope,
|
||||||
|
owner_id: Number(row.owner_id),
|
||||||
|
bytes: Number(row.total) || 0,
|
||||||
|
files: Number(row.files) || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total bytes across every directory.
|
||||||
|
*/
|
||||||
|
export async function grabTotalStorageUsage() {
|
||||||
|
const res = await BunSQLite.sql<{ total: number | null; files: number | null }>({
|
||||||
|
sql: `SELECT COALESCE(SUM(size), 0) AS total, COUNT(*) AS files FROM bot_files`,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: Number(res.payload?.[0]?.total) || 0,
|
||||||
|
files: Number(res.payload?.[0]?.files) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS,
|
||||||
|
BUN_SQLITE_COBBLE_BOT_FILES,
|
||||||
|
BunSQLiteTables,
|
||||||
|
} from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import embedText from "@/src/harness/embeddings/embed-text";
|
||||||
|
import HarnessError from "@/src/harness/harness-error";
|
||||||
|
import chunkText from "./chunk-text";
|
||||||
|
import extractBotFileText from "./extract-bot-file-text";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
file_id: number | string;
|
||||||
|
/**
|
||||||
|
* Read images and audio too (a bot asking for it).
|
||||||
|
*/
|
||||||
|
read_media?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IngestResult = {
|
||||||
|
/**
|
||||||
|
* `ingested`, `empty` (no text to read), `skipped` (too large) or
|
||||||
|
* `unavailable` (no embedding model), which keeps the file's text but
|
||||||
|
* leaves it searchable by words only.
|
||||||
|
*/
|
||||||
|
status: "ingested" | "empty" | "skipped" | "unavailable";
|
||||||
|
chunks: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a bot file's text, splits it into chunks and embeds them into
|
||||||
|
* `bot_file_vectors`, replacing whatever was stored before. The chunks
|
||||||
|
* stay in `bot_file_chunks` even when there's no embedding model, so a
|
||||||
|
* search can still match words in them.
|
||||||
|
*
|
||||||
|
* Failures are recorded on the file (`ingest_error`) and rethrown, so the
|
||||||
|
* queue can retry.
|
||||||
|
*/
|
||||||
|
export default async function ingestBotFile({
|
||||||
|
file_id,
|
||||||
|
read_media,
|
||||||
|
signal,
|
||||||
|
}: Params): Promise<IngestResult> {
|
||||||
|
const file = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT * FROM bot_files WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file_id],
|
||||||
|
}).then((res) => res.payload?.[0]);
|
||||||
|
|
||||||
|
if (!file?.id) return { status: "empty", chunks: 0 };
|
||||||
|
|
||||||
|
const media = file.media_id
|
||||||
|
? await BunSQLite.sql<{ media_write_path?: string; text_content?: string }>({
|
||||||
|
sql: `SELECT media_write_path, text_content FROM media WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file.media_id],
|
||||||
|
}).then((res) => res.payload?.[0])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (Number(file.size) > AppData.IngestMaxBytes && !media?.text_content) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_files SET ingest_error = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [`Too large to read (over ${AppData.IngestMaxBytes / (1024 * 1024)} MB).`, Date.now(), file.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: "skipped", chunks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await extractBotFileText({
|
||||||
|
write_path: media?.media_write_path,
|
||||||
|
text_content: media?.text_content,
|
||||||
|
name: file.name,
|
||||||
|
mime_type: file.mime_type,
|
||||||
|
read_media,
|
||||||
|
});
|
||||||
|
|
||||||
|
await clearChunks({ file_id: file.id });
|
||||||
|
|
||||||
|
if (!text?.trim()) {
|
||||||
|
await markIngested({ file_id: file.id, chunks: 0 });
|
||||||
|
|
||||||
|
return { status: "empty", chunks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = chunkText({ text });
|
||||||
|
|
||||||
|
if (!chunks.length) {
|
||||||
|
await markIngested({ file_id: file.id, chunks: 0 });
|
||||||
|
|
||||||
|
return { status: "empty", chunks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk_ids = await insertChunks({ file, chunks });
|
||||||
|
let embedded = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* A big file's chunks go in batches, so one provider refusal
|
||||||
|
* doesn't throw away the whole file.
|
||||||
|
*/
|
||||||
|
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
|
||||||
|
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
|
||||||
|
const { vectors } = await embedText({
|
||||||
|
input: batch,
|
||||||
|
bot_id: file.bot_id!,
|
||||||
|
workspace_id: file.scope == "workspace" ? file.owner_id : null,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < vectors.length; i++) {
|
||||||
|
await insertVector({
|
||||||
|
vector: vectors[i]!,
|
||||||
|
chunk_id: chunk_ids[start + i]!,
|
||||||
|
file_id: file.id,
|
||||||
|
scope: String(file.scope),
|
||||||
|
owner_id: Number(file.owner_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
embedded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessError && error.code == "unavailable") {
|
||||||
|
/**
|
||||||
|
* No embedding model: the chunks still let a search match
|
||||||
|
* words, so the file isn't a failure.
|
||||||
|
*/
|
||||||
|
await markIngested({
|
||||||
|
file_id: file.id,
|
||||||
|
chunks: chunks.length,
|
||||||
|
error: "No embedding model is set, so this file is searchable by words only.",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: "unavailable", chunks: chunks.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await markIngested({ file_id: file.id, chunks: chunks.length });
|
||||||
|
|
||||||
|
return { status: "ingested", chunks: embedded };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_files SET ingest_error = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [message.slice(0, 500), Date.now(), file.id],
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearChunks({ file_id }: { file_id: number }) {
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM bot_file_vectors WHERE file_id = ?`, values: [file_id] });
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM bot_file_chunks WHERE file_id = ?`, values: [file_id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertChunks({
|
||||||
|
file,
|
||||||
|
chunks,
|
||||||
|
}: {
|
||||||
|
file: BUN_SQLITE_COBBLE_BOT_FILES;
|
||||||
|
chunks: string[];
|
||||||
|
}) {
|
||||||
|
const ids: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < chunks.length; index++) {
|
||||||
|
const text = chunks[index]!;
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<
|
||||||
|
BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS,
|
||||||
|
(typeof BunSQLiteTables)[number]
|
||||||
|
>({
|
||||||
|
table: "bot_file_chunks",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
file_id: file.id,
|
||||||
|
scope: file.scope as any,
|
||||||
|
owner_id: Number(file.owner_id),
|
||||||
|
chunk_index: index,
|
||||||
|
text,
|
||||||
|
token_estimate: Math.ceil(text.length / 4),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (id) ids.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertVector({
|
||||||
|
vector,
|
||||||
|
chunk_id,
|
||||||
|
file_id,
|
||||||
|
scope,
|
||||||
|
owner_id,
|
||||||
|
}: {
|
||||||
|
vector: Float32Array;
|
||||||
|
chunk_id: number;
|
||||||
|
file_id: number;
|
||||||
|
scope: string;
|
||||||
|
owner_id: number;
|
||||||
|
}) {
|
||||||
|
/**
|
||||||
|
* vec0's metadata columns (its id and timestamps) are NOT NULL, so a
|
||||||
|
* raw insert has to fill them; `BunSQLite.insert` would add them but
|
||||||
|
* the vector needs `vec_f32`.
|
||||||
|
*/
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `INSERT INTO bot_file_vectors (created_at, updated_at, embedding, chunk_id, file_id, scope, owner_id) VALUES (?, ?, vec_f32(?), ?, ?, ?, ?)`,
|
||||||
|
values: [now, now, vector as any, chunk_id, file_id, scope, owner_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(`Couldn't store an embedding: ${res.error || res.msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markIngested({
|
||||||
|
file_id,
|
||||||
|
chunks,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
file_id: number;
|
||||||
|
chunks: number;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_files SET ingested_at = ?, ingest_error = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [now, error || null, now, file_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE media SET is_rag_ingested = ?, updated_at = ? WHERE id = (SELECT media_id FROM bot_files WHERE id = ?)`,
|
||||||
|
values: [chunks > 0 ? 1 : 0, now, file_id],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
/**
|
||||||
|
* Only this directory's files (a folder path), or, with `recursive`,
|
||||||
|
* everything under it.
|
||||||
|
*/
|
||||||
|
folder?: string | null;
|
||||||
|
recursive?: boolean;
|
||||||
|
/**
|
||||||
|
* Match the name or the path, case-insensitive.
|
||||||
|
*/
|
||||||
|
search?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files in the run's directories, newest first: one folder's, or
|
||||||
|
* everything under it, optionally matching a name.
|
||||||
|
*/
|
||||||
|
export default async function listBotFiles({
|
||||||
|
scopes,
|
||||||
|
folder,
|
||||||
|
recursive,
|
||||||
|
search,
|
||||||
|
limit = AppData.BotFileListMaxItems,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_FILES[]> {
|
||||||
|
if (!scopes.length) return [];
|
||||||
|
|
||||||
|
const target = (folder || "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||||
|
|
||||||
|
const owner_sql = scopes.map(() => `(scope = ? AND owner_id = ?)`).join(" OR ");
|
||||||
|
const owner_values = scopes.flatMap((s) => [s.scope, s.owner_id]);
|
||||||
|
|
||||||
|
const where = [owner_sql];
|
||||||
|
const values: (string | number)[] = [...owner_values];
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
where.push(
|
||||||
|
recursive
|
||||||
|
? `(folder = ? OR folder LIKE ?)`
|
||||||
|
: `folder = ?`,
|
||||||
|
);
|
||||||
|
|
||||||
|
values.push(target);
|
||||||
|
|
||||||
|
if (recursive) values.push(`${target}/%`);
|
||||||
|
} else if (!recursive) {
|
||||||
|
where.push(`(folder = '' OR folder IS NULL)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search?.trim()) {
|
||||||
|
where.push(`(name LIKE ? OR path LIKE ?)`);
|
||||||
|
values.push(`%${search.trim()}%`, `%${search.trim()}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT * FROM bot_files WHERE ${where.join(" AND ")} ORDER BY updated_at DESC, id DESC LIMIT ?`,
|
||||||
|
values: [...values, limit],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folders across the run's directories: a file's folder and every
|
||||||
|
* parent of it, so an empty folder still shows (folders are the paths
|
||||||
|
* that exist).
|
||||||
|
*/
|
||||||
|
export async function listBotFolders({
|
||||||
|
scopes,
|
||||||
|
limit = AppData.BotFileListMaxItems,
|
||||||
|
}: {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const files = await listBotFiles({ scopes, recursive: true, limit: 5000 });
|
||||||
|
const folders = new Map<string, { scope: string; owner_id: number; path: string }>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const segments = (file.folder || "").split("/").filter(Boolean);
|
||||||
|
|
||||||
|
for (let i = 1; i <= segments.length; i++) {
|
||||||
|
const path = segments.slice(0, i).join("/");
|
||||||
|
const key = `${file.scope}:${file.owner_id}:${path}`;
|
||||||
|
|
||||||
|
if (!folders.has(key)) {
|
||||||
|
folders.set(key, {
|
||||||
|
scope: String(file.scope),
|
||||||
|
owner_id: Number(file.owner_id),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folders.size >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...folders.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { MediaType } from "@/src/types";
|
||||||
|
import { BotImageFileExtensions } from "@/src/dict/bot-file-dict";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* A file name or an extension, e.g. `report.pdf` or `pdf`.
|
||||||
|
*/
|
||||||
|
name?: string | null;
|
||||||
|
mime_type?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioExtensions = ["mp3", "wav", "m4a", "ogg", "flac", "webm"];
|
||||||
|
const VideoExtensions = ["mp4", "mov", "mkv", "avi"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a file's `media_type` is, from its extension (else its MIME type):
|
||||||
|
* an image, a document, audio or video.
|
||||||
|
*/
|
||||||
|
export default function mediaTypeForFile({ name, mime_type }: Params): MediaType {
|
||||||
|
const ext = extensionOf({ name, mime_type });
|
||||||
|
|
||||||
|
if (ext && BotImageFileExtensions.includes(ext)) return "image";
|
||||||
|
if (ext && AudioExtensions.includes(ext)) return "audio";
|
||||||
|
if (ext && VideoExtensions.includes(ext)) return "video";
|
||||||
|
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Its extension, lower case, without the dot.
|
||||||
|
*/
|
||||||
|
export function extensionOf({ name, mime_type }: Params) {
|
||||||
|
const from_name = name?.includes(".") ? name.split(".").pop() : undefined;
|
||||||
|
|
||||||
|
if (from_name) return from_name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
|
||||||
|
const from_mime = mime_type && !mime_type.includes("octet-stream")
|
||||||
|
? mime_type.split("/").pop()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return from_mime ? from_mime.toLowerCase().replace(/[^a-z0-9]/g, "") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A MIME type for the row, from the extension when there isn't one.
|
||||||
|
*/
|
||||||
|
export function mimeTypeForFile({ name, mime_type }: Params) {
|
||||||
|
if (mime_type && mime_type != "application/octet-stream") return mime_type;
|
||||||
|
|
||||||
|
const ext = extensionOf({ name });
|
||||||
|
|
||||||
|
return (ext && MediaTypesByExtension[ext]) || "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The types worth knowing, so nothing has to guess (SNIFFING a file for
|
||||||
|
* this would mean reading it twice).
|
||||||
|
*/
|
||||||
|
const MediaTypesByExtension: Record<string, string> = {
|
||||||
|
txt: "text/plain",
|
||||||
|
text: "text/plain",
|
||||||
|
log: "text/plain",
|
||||||
|
md: "text/markdown",
|
||||||
|
markdown: "text/markdown",
|
||||||
|
csv: "text/csv",
|
||||||
|
tsv: "text/tab-separated-values",
|
||||||
|
json: "application/json",
|
||||||
|
html: "text/html",
|
||||||
|
htm: "text/html",
|
||||||
|
xml: "application/xml",
|
||||||
|
yml: "application/yaml",
|
||||||
|
yaml: "application/yaml",
|
||||||
|
pdf: "application/pdf",
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
doc: "application/msword",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
webp: "image/webp",
|
||||||
|
gif: "image/gif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
mp3: "audio/mpeg",
|
||||||
|
wav: "audio/wav",
|
||||||
|
m4a: "audio/mp4",
|
||||||
|
ogg: "audio/ogg",
|
||||||
|
flac: "audio/flac",
|
||||||
|
mp4: "video/mp4",
|
||||||
|
mov: "video/quicktime",
|
||||||
|
webm: "video/webm",
|
||||||
|
zip: "application/zip",
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { existsSync, mkdirSync, renameSync, rmSync, copyFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import resolveBotPath from "@/src/utils/resolve-bot-path";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
import { canReachScope } from "@/src/utils/storage-access";
|
||||||
|
import grabBotFile from "./grab-bot-file";
|
||||||
|
import grabStorageDir from "./grab-storage-dir";
|
||||||
|
import { grabBotFileRow } from "./record-bot-file";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
file_id: number | string;
|
||||||
|
/**
|
||||||
|
* The new path, in the file's own directory.
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames a file or moves it into another folder, inside its own
|
||||||
|
* directory. The bytes move on disk and the row follows; an existing file
|
||||||
|
* at the target is refused, never overwritten by a rename.
|
||||||
|
*/
|
||||||
|
export default async function moveBotFile({ scopes, file_id, path: target }: Params) {
|
||||||
|
const file = await grabBotFile({ scopes, file_id });
|
||||||
|
|
||||||
|
if (!file?.id) {
|
||||||
|
throw new AppError(`That file isn't in your files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canReachScope({ scopes, scope: file.scope as any, owner_id: file.owner_id! })) {
|
||||||
|
throw new AppError(`That file isn't in your files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
|
||||||
|
const resolved = resolveBotPath({ root, path: target });
|
||||||
|
|
||||||
|
if (resolved.relative == file.path) return file;
|
||||||
|
|
||||||
|
const taken = await grabBotFileRow({
|
||||||
|
scope: file.scope as any,
|
||||||
|
owner_id: file.owner_id!,
|
||||||
|
path: resolved.relative,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (taken?.id) {
|
||||||
|
throw new AppError(`There's already a file at ${resolved.relative}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const from = path.join(root, file.path || "");
|
||||||
|
|
||||||
|
mkdirSync(path.dirname(resolved.absolute), { recursive: true });
|
||||||
|
|
||||||
|
if (existsSync(from)) {
|
||||||
|
try {
|
||||||
|
renameSync(from, resolved.absolute);
|
||||||
|
} catch {
|
||||||
|
copyFileSync(from, resolved.absolute);
|
||||||
|
rmSync(from, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_files SET path = ?, name = ?, folder = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [resolved.relative, resolved.name, resolved.folder, Date.now(), file.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (file.media_id) {
|
||||||
|
const write_path = resolved.absolute.startsWith(DATA_DIR)
|
||||||
|
? resolved.absolute.slice(DATA_DIR.length).replace(/^[\\/]/, "").split("\\").join("/")
|
||||||
|
: resolved.relative;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE media SET media_write_path = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [write_path, Date.now(), file.media_id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...file, path: resolved.relative, name: resolved.name, folder: resolved.folder };
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { BotFileScope } from "@/src/dict/bot-file-dict";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
import grabStorageDir from "./grab-storage-dir";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scope: BotFileScope;
|
||||||
|
owner_id: number | string;
|
||||||
|
/**
|
||||||
|
* Its files' `media` ids, read before the rows go. Left out, they are
|
||||||
|
* read here (only useful when the catalog rows still exist).
|
||||||
|
*/
|
||||||
|
media_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `media` ids of a directory's files, read before the bot or workspace
|
||||||
|
* (and with it, the catalog rows) is deleted.
|
||||||
|
*/
|
||||||
|
export async function grabStorageMediaIds({ scope, owner_id }: { scope: BotFileScope; owner_id: number | string }) {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT media_id FROM bot_files WHERE scope = ? AND owner_id = ?`,
|
||||||
|
values: [scope, owner_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (res.payload || []).map((file) => Number(file.media_id)).filter((id) => id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a directory's files for good when the bot or workspace it
|
||||||
|
* belongs to goes: the disk folder, every file's `media` row, and what is
|
||||||
|
* left of the catalog. A failure is logged, never thrown: deleting the
|
||||||
|
* bot must not stop on a stray file.
|
||||||
|
*/
|
||||||
|
export default async function purgeStorage({ scope, owner_id, media_ids }: Params) {
|
||||||
|
try {
|
||||||
|
const ids = media_ids || (await grabStorageMediaIds({ scope, owner_id }));
|
||||||
|
|
||||||
|
if (ids.length) {
|
||||||
|
const placeholders = ids.map(() => "?").join(", ");
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM media_paradigms WHERE media_id IN (${placeholders})`,
|
||||||
|
values: ids,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM media WHERE id IN (${placeholders})`,
|
||||||
|
values: ids,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM bot_file_vectors WHERE scope = ? AND owner_id = ?`,
|
||||||
|
values: [scope, owner_id],
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usually gone already, with the bot or workspace (the cascade).
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM bot_files WHERE scope = ? AND owner_id = ?`,
|
||||||
|
values: [scope, owner_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dir = grabStorageDir({ scope, owner_id, create: false });
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never outside the data volume, whatever the path says.
|
||||||
|
*/
|
||||||
|
if (path.resolve(dir).startsWith(path.resolve(DATA_DIR))) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await logError({
|
||||||
|
title: `${scope} ${owner_id}'s files couldn't be cleaned up`,
|
||||||
|
error,
|
||||||
|
metadata: { scope, owner_id },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_BOT_FILES,
|
||||||
|
BUN_SQLITE_COBBLE_MEDIA,
|
||||||
|
BunSQLiteTables,
|
||||||
|
} from "@/db/types/db";
|
||||||
|
import type { BotFilePurpose, BotFileScope, BotFileSource } from "@/src/dict/bot-file-dict";
|
||||||
|
import type { MediaType } from "@/src/types";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import resolveBotPath from "@/src/utils/resolve-bot-path";
|
||||||
|
import assertStorageRoom from "./assert-storage-room";
|
||||||
|
import grabStorageDir from "./grab-storage-dir";
|
||||||
|
import mediaTypeForFile, { mimeTypeForFile } from "./media-type-for-file";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The bot saving it (who `bot_files.bot_id` names).
|
||||||
|
*/
|
||||||
|
bot_id: number | string;
|
||||||
|
scope: BotFileScope;
|
||||||
|
/**
|
||||||
|
* The bot or workspace owning the directory.
|
||||||
|
*/
|
||||||
|
owner_id: number | string;
|
||||||
|
/**
|
||||||
|
* Path inside the directory, e.g. `reports/q3.md`.
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
data: Uint8Array | string;
|
||||||
|
purpose?: BotFilePurpose;
|
||||||
|
source?: BotFileSource;
|
||||||
|
/**
|
||||||
|
* Overrides the type read from the name.
|
||||||
|
*/
|
||||||
|
media_type?: MediaType;
|
||||||
|
mime_type?: string;
|
||||||
|
/**
|
||||||
|
* Who the `media` row says uploaded it: the user the run is for.
|
||||||
|
*/
|
||||||
|
user_id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a file into a storage directory: the bytes on the data volume and
|
||||||
|
* the catalog row holding them, as one step. Every writer goes through
|
||||||
|
* here (a bot's `write_file`, an upload, a generated image, a PDF), so
|
||||||
|
* disk and database never disagree.
|
||||||
|
*
|
||||||
|
* Writing over an existing path keeps its `bot_files` row and its `media`
|
||||||
|
* row, so a message that attached the file now shows the new bytes.
|
||||||
|
*/
|
||||||
|
export default async function recordBotFile({
|
||||||
|
bot_id,
|
||||||
|
scope,
|
||||||
|
owner_id,
|
||||||
|
path,
|
||||||
|
data,
|
||||||
|
purpose = "writing",
|
||||||
|
source = "bot-write",
|
||||||
|
media_type,
|
||||||
|
mime_type,
|
||||||
|
user_id,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_FILES> {
|
||||||
|
const root = grabStorageDir({ scope, owner_id });
|
||||||
|
const resolved = resolveBotPath({ root, path });
|
||||||
|
const bytes = typeof data == "string" ? Buffer.from(data, "utf8") : data;
|
||||||
|
const media_type_resolved = media_type || mediaTypeForFile({ name: resolved.name, mime_type });
|
||||||
|
const mime = mimeTypeForFile({ name: resolved.name, mime_type });
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
|
||||||
|
const existing = await grabBotFileRow({ scope, owner_id, path: resolved.relative });
|
||||||
|
const previous_size = Number(existing?.size) || 0;
|
||||||
|
|
||||||
|
await assertStorageRoom({
|
||||||
|
scope,
|
||||||
|
owner_id,
|
||||||
|
incoming_bytes: bytes.byteLength - previous_size,
|
||||||
|
});
|
||||||
|
|
||||||
|
const absolute = resolved.absolute;
|
||||||
|
const write_path = absolute.startsWith(DATA_DIR)
|
||||||
|
? absolute.slice(DATA_DIR.length).replace(/^[\\/]/, "").split("\\").join("/")
|
||||||
|
: resolved.relative;
|
||||||
|
|
||||||
|
await Bun.write(absolute, bytes);
|
||||||
|
|
||||||
|
const media_id = existing?.media_id
|
||||||
|
? await updateMediaRow({
|
||||||
|
media_id: existing.media_id,
|
||||||
|
write_path,
|
||||||
|
mime,
|
||||||
|
media_type: media_type_resolved,
|
||||||
|
})
|
||||||
|
: await insertMediaRow({
|
||||||
|
write_path,
|
||||||
|
mime,
|
||||||
|
media_type: media_type_resolved,
|
||||||
|
title: resolved.name,
|
||||||
|
user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (existing?.id) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_files SET media_id = ?, name = ?, folder = ?, mime_type = ?, media_type = ?, size = ?,
|
||||||
|
purpose = ?, source = ?, ingested_at = NULL, ingest_error = NULL, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [
|
||||||
|
media_id,
|
||||||
|
resolved.name,
|
||||||
|
resolved.folder,
|
||||||
|
mime,
|
||||||
|
media_type_resolved,
|
||||||
|
bytes.byteLength,
|
||||||
|
purpose,
|
||||||
|
source,
|
||||||
|
now,
|
||||||
|
existing.id,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
media_id,
|
||||||
|
name: resolved.name,
|
||||||
|
folder: resolved.folder,
|
||||||
|
mime_type: mime,
|
||||||
|
media_type: media_type_resolved as any,
|
||||||
|
size: bytes.byteLength,
|
||||||
|
purpose,
|
||||||
|
source,
|
||||||
|
ingested_at: undefined,
|
||||||
|
ingest_error: undefined,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_FILES, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "bot_files",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
bot_id: Number(bot_id),
|
||||||
|
scope,
|
||||||
|
owner_id: Number(owner_id),
|
||||||
|
media_id,
|
||||||
|
path: resolved.relative,
|
||||||
|
name: resolved.name,
|
||||||
|
folder: resolved.folder,
|
||||||
|
mime_type: mime,
|
||||||
|
media_type: media_type_resolved as any,
|
||||||
|
size: bytes.byteLength,
|
||||||
|
purpose,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!res.success || !id) {
|
||||||
|
throw new Error(`Couldn't record the file: ${res.msg || res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
bot_id: Number(bot_id),
|
||||||
|
scope,
|
||||||
|
owner_id: Number(owner_id),
|
||||||
|
media_id,
|
||||||
|
path: resolved.relative,
|
||||||
|
name: resolved.name,
|
||||||
|
folder: resolved.folder,
|
||||||
|
mime_type: mime,
|
||||||
|
media_type: media_type_resolved as any,
|
||||||
|
size: bytes.byteLength,
|
||||||
|
purpose,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog row at a path, if there is one.
|
||||||
|
*/
|
||||||
|
export async function grabBotFileRow({
|
||||||
|
scope,
|
||||||
|
owner_id,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
scope: BotFileScope;
|
||||||
|
owner_id: number | string;
|
||||||
|
path: string;
|
||||||
|
}) {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT * FROM bot_files WHERE scope = ? AND owner_id = ? AND path = ? LIMIT 1`,
|
||||||
|
values: [scope, owner_id, path],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertMediaRow({
|
||||||
|
write_path,
|
||||||
|
mime,
|
||||||
|
media_type,
|
||||||
|
title,
|
||||||
|
user_id,
|
||||||
|
}: {
|
||||||
|
write_path: string;
|
||||||
|
mime: string;
|
||||||
|
media_type: MediaType;
|
||||||
|
title: string;
|
||||||
|
user_id?: number | string;
|
||||||
|
}) {
|
||||||
|
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_MEDIA, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "media",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
user_id: Number(user_id) || 0,
|
||||||
|
title,
|
||||||
|
media_type: media_type as any,
|
||||||
|
media_write_path: write_path,
|
||||||
|
mime_type: mime,
|
||||||
|
is_private: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!res.success || !id) {
|
||||||
|
throw new Error(`Couldn't record the file's bytes: ${res.msg || res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.insert({
|
||||||
|
table: "media_paradigms",
|
||||||
|
data: [{ media_id: id, media_paradigm: "bot-file" as any }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMediaRow({
|
||||||
|
media_id,
|
||||||
|
write_path,
|
||||||
|
mime,
|
||||||
|
media_type,
|
||||||
|
}: {
|
||||||
|
media_id: number | string;
|
||||||
|
write_path: string;
|
||||||
|
mime: string;
|
||||||
|
media_type: MediaType;
|
||||||
|
}): Promise<number> {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE media SET media_write_path = ?, mime_type = ?, media_type = ?, is_rag_ingested = 0, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [write_path, mime, media_type, Date.now(), media_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(media_id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { embedQuery } from "@/src/harness/embeddings/embed-text";
|
||||||
|
import HarnessError from "@/src/harness/harness-error";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
query: string;
|
||||||
|
/**
|
||||||
|
* Only this file's chunks.
|
||||||
|
*/
|
||||||
|
file_id?: number | string | null;
|
||||||
|
limit?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* Whose embedding model to use.
|
||||||
|
*/
|
||||||
|
bot_id?: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotFileSearchHit = {
|
||||||
|
file_id: number;
|
||||||
|
chunk_id: number;
|
||||||
|
chunk_index: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
scope: string;
|
||||||
|
/**
|
||||||
|
* Cosine distance when the vector index answered, null for a word
|
||||||
|
* match.
|
||||||
|
*/
|
||||||
|
distance: number | null;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotFileSearchResult = {
|
||||||
|
hits: BotFileSearchHit[];
|
||||||
|
/**
|
||||||
|
* How the search ran: `vector`, `words` (no embedding model, or nothing
|
||||||
|
* matched) or `none` (no files at all).
|
||||||
|
*/
|
||||||
|
mode: "vector" | "words" | "none";
|
||||||
|
/**
|
||||||
|
* Said plainly for the bot's own reading of what it got back.
|
||||||
|
*/
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches a run's files: by meaning through the embedding vectors when a
|
||||||
|
* model is set, else (or when nothing matches) by words over the stored
|
||||||
|
* chunks. Never throws for an unavailable model: the bot always gets
|
||||||
|
* something it can act on.
|
||||||
|
*/
|
||||||
|
export default async function searchBotFiles({
|
||||||
|
scopes,
|
||||||
|
query,
|
||||||
|
file_id,
|
||||||
|
limit = AppData.RagTopChunks,
|
||||||
|
signal,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BotFileSearchResult> {
|
||||||
|
const text = query.trim();
|
||||||
|
|
||||||
|
if (!scopes.length || !text) {
|
||||||
|
return { hits: [], mode: "none", note: "No files to search." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vector = await embedQuery({
|
||||||
|
text,
|
||||||
|
signal,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = await vectorSearch({
|
||||||
|
scopes,
|
||||||
|
query_vector: vector,
|
||||||
|
file_id,
|
||||||
|
limit,
|
||||||
|
seek: Math.min(limit * AppData.RagOverFetch, AppData.RagMaxK),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hits.length) {
|
||||||
|
return {
|
||||||
|
hits,
|
||||||
|
mode: "vector",
|
||||||
|
note: `${hits.length} passage(s), found by meaning.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof HarnessError)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = await wordSearch({ scopes, query: text, file_id, limit });
|
||||||
|
|
||||||
|
return {
|
||||||
|
hits,
|
||||||
|
mode: hits.length ? "words" : "none",
|
||||||
|
note: hits.length
|
||||||
|
? `${hits.length} passage(s), found by matching words (no embedding model, or nothing matched by meaning).`
|
||||||
|
: `Nothing in your files matched "${text}".`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nearest chunks by vector. sqlite-vec refuses a filter on a vec0
|
||||||
|
* side-car column in the same query as the match, so the index is asked
|
||||||
|
* for its nearest `seek` and the run's scopes are applied to that list
|
||||||
|
* here, before the chunk text is read.
|
||||||
|
*/
|
||||||
|
async function vectorSearch({
|
||||||
|
scopes,
|
||||||
|
query_vector,
|
||||||
|
file_id,
|
||||||
|
limit,
|
||||||
|
seek,
|
||||||
|
}: {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
query_vector: Float32Array;
|
||||||
|
file_id?: number | string | null;
|
||||||
|
limit: number;
|
||||||
|
seek: number;
|
||||||
|
}): Promise<BotFileSearchHit[]> {
|
||||||
|
const nearest = await BunSQLite.sql<{
|
||||||
|
chunk_id: number;
|
||||||
|
file_id: number;
|
||||||
|
scope: string;
|
||||||
|
owner_id: number;
|
||||||
|
distance: number;
|
||||||
|
}>({
|
||||||
|
sql: `SELECT chunk_id, file_id, scope, owner_id, distance FROM bot_file_vectors WHERE embedding MATCH vec_f32(?) AND k = ? ORDER BY distance`,
|
||||||
|
values: [query_vector as any, seek],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `BunSQLite.sql` resolves with `success: false` instead of throwing, so
|
||||||
|
* a broken vector query would otherwise read as "nothing matched" and
|
||||||
|
* silently fall back to words. A failure here is a real error.
|
||||||
|
*/
|
||||||
|
if (!nearest.success) {
|
||||||
|
throw new Error(`The file search index couldn't be read: ${nearest.error || nearest.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = nearest.payload?.filter((row) =>
|
||||||
|
scopes.some((scope) => scope.scope == row.scope && Number(scope.owner_id) == Number(row.owner_id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const picked = (allowed || [])
|
||||||
|
.filter((row) => !file_id || Number(row.file_id) == Number(file_id))
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
if (!picked.length) return [];
|
||||||
|
|
||||||
|
const chunk_ids = picked.map((row) => Number(row.chunk_id));
|
||||||
|
const placeholders = chunk_ids.map(() => "?").join(", ");
|
||||||
|
|
||||||
|
const chunks = await BunSQLite.sql<{
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
chunk_index: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}>({
|
||||||
|
sql: `SELECT c.id, c.text, c.chunk_index, f.name, f.path FROM bot_file_chunks c
|
||||||
|
JOIN bot_files f ON f.id = c.file_id
|
||||||
|
WHERE c.id IN (${placeholders})`,
|
||||||
|
values: chunk_ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
const by_id = new Map((chunks.payload || []).map((row) => [Number(row.id), row]));
|
||||||
|
|
||||||
|
return picked.flatMap((row) => {
|
||||||
|
const chunk = by_id.get(Number(row.chunk_id));
|
||||||
|
|
||||||
|
if (!chunk) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
toHit({
|
||||||
|
chunk_id: row.chunk_id,
|
||||||
|
file_id: row.file_id,
|
||||||
|
scope: row.scope,
|
||||||
|
distance: Number(row.distance),
|
||||||
|
text: chunk.text,
|
||||||
|
chunk_index: chunk.chunk_index,
|
||||||
|
name: chunk.name,
|
||||||
|
path: chunk.path,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunks whose text has the query's words, newest files first. Used when
|
||||||
|
* there's no embedding model, and as the fallback when the vectors
|
||||||
|
* answered nothing.
|
||||||
|
*/
|
||||||
|
async function wordSearch({
|
||||||
|
scopes,
|
||||||
|
query,
|
||||||
|
file_id,
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
query: string;
|
||||||
|
file_id?: number | string | null;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<BotFileSearchHit[]> {
|
||||||
|
const words = [
|
||||||
|
...new Set(
|
||||||
|
query
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter((word) => word.length > 2),
|
||||||
|
),
|
||||||
|
].slice(0, 6);
|
||||||
|
|
||||||
|
if (!words.length) return [];
|
||||||
|
|
||||||
|
const scope_sql = scopes.map(() => `(c.scope = ? AND c.owner_id = ?)`).join(" OR ");
|
||||||
|
const scope_values = scopes.flatMap((s) => [s.scope, s.owner_id]);
|
||||||
|
const word_sql = words.map(() => `lower(c.text) LIKE ?`).join(" OR ");
|
||||||
|
const word_values = words.map((word) => `%${word}%`);
|
||||||
|
const file_clause = file_id ? "AND c.file_id = ?" : "";
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<{
|
||||||
|
chunk_id: number;
|
||||||
|
file_id: number;
|
||||||
|
text: string;
|
||||||
|
chunk_index: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
scope: string;
|
||||||
|
}>({
|
||||||
|
sql: `SELECT c.id AS chunk_id, c.file_id, c.text, c.chunk_index, c.scope, f.name, f.path
|
||||||
|
FROM bot_file_chunks c
|
||||||
|
JOIN bot_files f ON f.id = c.file_id
|
||||||
|
WHERE (${scope_sql}) ${file_clause} AND (${word_sql})
|
||||||
|
ORDER BY f.updated_at DESC, c.chunk_index
|
||||||
|
LIMIT ?`,
|
||||||
|
values: [...scope_values, ...(file_id ? [file_id] : []), ...word_values, limit],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (res.payload || []).map((row) => toHit({ ...row, distance: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHit(row: {
|
||||||
|
chunk_id: number;
|
||||||
|
file_id: number;
|
||||||
|
text: string;
|
||||||
|
chunk_index: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
scope: string;
|
||||||
|
distance: number | null;
|
||||||
|
}): BotFileSearchHit {
|
||||||
|
return {
|
||||||
|
file_id: Number(row.file_id),
|
||||||
|
chunk_id: Number(row.chunk_id),
|
||||||
|
chunk_index: Number(row.chunk_index),
|
||||||
|
name: row.name || "",
|
||||||
|
path: row.path || "",
|
||||||
|
scope: row.scope || "bot",
|
||||||
|
distance: row.distance == null ? null : Number(row.distance),
|
||||||
|
text: row.text || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
|
import { BotUploadFolder } from "@/src/dict/bot-file-dict";
|
||||||
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import recordBotFile from "./record-bot-file";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
user_id: number | string;
|
||||||
|
media: BUN_SQLITE_COBBLE_MEDIA[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the files the user attached to a message into the conversation's
|
||||||
|
* folder (`uploads/<yyyy-mm>/<name>`), so they stay searchable after the
|
||||||
|
* message scrolls away and a later run can find them. Best effort: a
|
||||||
|
* failure is logged, never shown, and the message still goes out.
|
||||||
|
*/
|
||||||
|
export default async function storeChatAttachments({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
user_id,
|
||||||
|
media,
|
||||||
|
}: Params) {
|
||||||
|
if (!media.length) return;
|
||||||
|
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
|
||||||
|
|
||||||
|
for (const file of media) {
|
||||||
|
try {
|
||||||
|
if (!file.media_write_path) continue;
|
||||||
|
|
||||||
|
const source = Bun.file(`${DATA_DIR}/${file.media_write_path}`);
|
||||||
|
|
||||||
|
if (!(await source.exists())) continue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Its name, made safe and unique enough that a second upload
|
||||||
|
* of "invoice.pdf" doesn't overwrite the first.
|
||||||
|
*/
|
||||||
|
const name = safeName({ name: file.title || `file-${file.id}` });
|
||||||
|
|
||||||
|
const saved = await recordBotFile({
|
||||||
|
bot_id,
|
||||||
|
scope: workspace_id ? "workspace" : "bot",
|
||||||
|
owner_id: workspace_id || bot_id,
|
||||||
|
path: `${folder}/${name}`,
|
||||||
|
data: new Uint8Array(await source.arrayBuffer()),
|
||||||
|
purpose: "upload",
|
||||||
|
source: "chat-upload",
|
||||||
|
media_type: (file.media_type as any) || undefined,
|
||||||
|
mime_type: file.mime_type || undefined,
|
||||||
|
user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same text the upload already extracted, so a PDF isn't
|
||||||
|
* unpacked a second time.
|
||||||
|
*/
|
||||||
|
if (file.text_content && saved.media_id) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE media SET text_content = ? WHERE id = ?`,
|
||||||
|
values: [file.text_content, saved.media_id],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await enqueueFileIngest({ file_id: saved.id! });
|
||||||
|
} catch (error) {
|
||||||
|
await logError({
|
||||||
|
title: `A chat attachment couldn't be saved to the bot's files`,
|
||||||
|
error,
|
||||||
|
metadata: { media_id: file.id, bot_id },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file name that is safe on disk.
|
||||||
|
*/
|
||||||
|
function safeName({ name }: { name: string }) {
|
||||||
|
const cleaned = name
|
||||||
|
.replace(/[<>:"|?*/\\]/g, "-")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 100);
|
||||||
|
|
||||||
|
return cleaned || "file";
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import * as sqliteVec from "sqlite-vec";
|
||||||
|
import fitVectorDimensions from "@/src/harness/embeddings/fit-vector-dimensions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact insert and nearest-neighbour shape `ingestBotFile` and
|
||||||
|
* `searchBotFiles` use, on a scratch database: vec0's metadata columns are
|
||||||
|
* NOT NULL, and a side-car filter can't share the match query.
|
||||||
|
*/
|
||||||
|
function createDb() {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
sqliteVec.load(db);
|
||||||
|
db.run(`CREATE VIRTUAL TABLE bot_file_vectors USING vec0(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER,
|
||||||
|
embedding float[8],
|
||||||
|
+chunk_id INTEGER,
|
||||||
|
+file_id INTEGER,
|
||||||
|
+scope TEXT,
|
||||||
|
+owner_id INTEGER
|
||||||
|
)`);
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vector = (values: number[]) => fitVectorDimensions({ vector: values, dimensions: 8 });
|
||||||
|
|
||||||
|
const insert = (db: Database, row: { chunk_id: number; file_id: number; scope: string; owner_id: number; v: number[] }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO bot_file_vectors (created_at, updated_at, embedding, chunk_id, file_id, scope, owner_id) VALUES (?, ?, vec_f32(?), ?, ?, ?, ?)`,
|
||||||
|
[now, now, vector(row.v), row.chunk_id, row.file_id, row.scope, row.owner_id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("bot file vectors", () => {
|
||||||
|
test("stores a vector and matches the nearest one", () => {
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0, 0, 0] });
|
||||||
|
insert(db, { chunk_id: 2, file_id: 11, scope: "bot", owner_id: 1, v: [0, 1, 0, 0] });
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.query(
|
||||||
|
`SELECT chunk_id, file_id, distance FROM bot_file_vectors WHERE embedding MATCH vec_f32(?1) AND k = 2 ORDER BY distance`,
|
||||||
|
)
|
||||||
|
.all(vector([0.9, 0.1, 0, 0])) as { chunk_id: number; distance: number }[];
|
||||||
|
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
expect(rows[0]!.chunk_id).toBe(1);
|
||||||
|
expect(rows[0]!.distance).toBeLessThan(rows[1]!.distance);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters scopes in the app, not in the match query", () => {
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0] });
|
||||||
|
insert(db, { chunk_id: 2, file_id: 20, scope: "bot", owner_id: 2, v: [1, 0.05] });
|
||||||
|
insert(db, { chunk_id: 3, file_id: 30, scope: "workspace", owner_id: 3, v: [1, 0.1] });
|
||||||
|
|
||||||
|
const nearest = db
|
||||||
|
.query(`SELECT chunk_id, scope, owner_id, distance FROM bot_file_vectors WHERE embedding MATCH vec_f32(?1) AND k = 10 ORDER BY distance`)
|
||||||
|
.all(vector([1, 0])) as { chunk_id: number; scope: string; owner_id: number }[];
|
||||||
|
|
||||||
|
expect(nearest.length).toBe(3);
|
||||||
|
|
||||||
|
const mine = nearest.filter((row) => row.scope == "bot" && row.owner_id == 1);
|
||||||
|
|
||||||
|
expect(mine.map((row) => row.chunk_id)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a side-car filter in the match query is refused", () => {
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0] });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
db
|
||||||
|
.query(
|
||||||
|
`SELECT chunk_id FROM bot_file_vectors WHERE embedding MATCH vec_f32(?1) AND k = 2 AND scope = 'bot'`,
|
||||||
|
)
|
||||||
|
.all(vector([1, 0])),
|
||||||
|
).toThrow(/illegal WHERE constraint/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an insert without vec0's metadata columns is refused", () => {
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO bot_file_vectors (embedding, chunk_id, file_id, scope, owner_id) VALUES (vec_f32(?1), 1, 10, 'bot', 1)`,
|
||||||
|
[vector([1, 0])],
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes a file's vectors by its side-car column", () => {
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0] });
|
||||||
|
|
||||||
|
db.run(`DELETE FROM bot_file_vectors WHERE file_id = ?`, [10]);
|
||||||
|
|
||||||
|
const count = db.query(`SELECT COUNT(*) AS n FROM bot_file_vectors`).get() as { n: number };
|
||||||
|
|
||||||
|
expect(count.n).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fitVectorDimensions", () => {
|
||||||
|
test("pads a short vector and normalizes it", () => {
|
||||||
|
const out = fitVectorDimensions({ vector: [3, 4], dimensions: 4 });
|
||||||
|
const norm = Math.sqrt([...out].reduce((sum, value) => sum + value * value, 0));
|
||||||
|
|
||||||
|
expect(out.length).toBe(4);
|
||||||
|
expect(Math.round(norm * 1000) / 1000).toBe(1);
|
||||||
|
expect(Math.round(out[0]! * 1000) / 1000).toBe(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cuts a long vector", () => {
|
||||||
|
expect(fitVectorDimensions({ vector: [1, 1, 1, 1, 1, 1], dimensions: 2 }).length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a zero vector as zeros", () => {
|
||||||
|
expect([...fitVectorDimensions({ vector: [0, 0], dimensions: 2 })]).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {});
|
||||||
@@ -7,6 +7,7 @@ import grabBots from "../bots/grab-bots";
|
|||||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||||
import grabBotMessages from "../bots/grab-bot-messages";
|
import grabBotMessages from "../bots/grab-bot-messages";
|
||||||
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||||
|
import grabConversationFiles from "../bot-files/grab-conversation-files";
|
||||||
import grabBotComputers from "../computers/grab-bot-computers";
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||||
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||||
@@ -67,7 +68,7 @@ export default async function grabConversationProps({
|
|||||||
|
|
||||||
bot.unread = 0;
|
bot.unread = 0;
|
||||||
|
|
||||||
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
|
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
||||||
grabBotMessages({
|
grabBotMessages({
|
||||||
session_id: session.id!,
|
session_id: session.id!,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -77,6 +78,7 @@ export default async function grabConversationProps({
|
|||||||
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
||||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
||||||
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
||||||
|
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
|
||||||
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
||||||
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
||||||
]);
|
]);
|
||||||
@@ -95,6 +97,7 @@ export default async function grabConversationProps({
|
|||||||
bot_computers,
|
bot_computers,
|
||||||
bot_task,
|
bot_task,
|
||||||
bot_extensions,
|
bot_extensions,
|
||||||
|
bot_files,
|
||||||
bot_voice: { transcription, speech },
|
bot_voice: { transcription, speech },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ type Params = {
|
|||||||
* Finish without posting a message.
|
* Finish without posting a message.
|
||||||
*/
|
*/
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
|
/**
|
||||||
|
* Files the bot saved and asked to send with it (`media` ids).
|
||||||
|
*/
|
||||||
|
attachments?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +47,7 @@ export default async function completeBotJob({
|
|||||||
result,
|
result,
|
||||||
needs_you,
|
needs_you,
|
||||||
silent,
|
silent,
|
||||||
|
attachments,
|
||||||
}: Params) {
|
}: Params) {
|
||||||
const job = await grabLockedBotJob({ job_id, worker });
|
const job = await grabLockedBotJob({ job_id, worker });
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@ export default async function completeBotJob({
|
|||||||
workspace_id: job.workspace_id || null,
|
workspace_id: job.workspace_id || null,
|
||||||
text: result,
|
text: result,
|
||||||
needs_you,
|
needs_you,
|
||||||
|
media_ids: attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
const user_id = await grabJobUserId({ job });
|
const user_id = await grabJobUserId({ job });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||||
|
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabUserBot from "./grab-user-bot";
|
import grabUserBot from "./grab-user-bot";
|
||||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||||
@@ -27,6 +28,13 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
|||||||
throw new AppError(`The coordinator can't be deleted.`);
|
throw new AppError(`The coordinator can't be deleted.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read before the delete: the files' rows go with the bot.
|
||||||
|
*/
|
||||||
|
const file_media_ids = await grabStorageMediaIds({ scope: "bot", owner_id: bot.id! }).catch(
|
||||||
|
() => [],
|
||||||
|
);
|
||||||
|
|
||||||
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
@@ -37,5 +45,11 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
|||||||
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Its folder goes with it: the bytes, their `media` rows and the search
|
||||||
|
* vectors.
|
||||||
|
*/
|
||||||
|
await purgeStorage({ scope: "bot", owner_id: bot.id!, media_ids: file_media_ids });
|
||||||
|
|
||||||
return bot;
|
return bot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import grabMessagesMedia from "./grab-messages-media";
|
import grabMessagesMedia from "./grab-messages-media";
|
||||||
import grabMessagesReactions from "./grab-messages-reactions";
|
import grabMessagesReactions from "./grab-messages-reactions";
|
||||||
import grabMessagesReplies from "./grab-messages-replies";
|
import grabMessagesReplies from "./grab-messages-replies";
|
||||||
import mediaToAttachment from "./media-to-attachment";
|
import mediaToAttachment, { grabAttachmentFileIds } from "./media-to-attachment";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
session_id: number | string;
|
session_id: number | string;
|
||||||
@@ -61,6 +61,14 @@ export default async function grabBotMessages({
|
|||||||
: Promise.resolve(new Map()),
|
: Promise.resolve(new Map()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files that live in a bot's folder carry their `bot_files` id, so a
|
||||||
|
* chip opens the file rather than only its bytes.
|
||||||
|
*/
|
||||||
|
const file_ids = await grabAttachmentFileIds({
|
||||||
|
media_ids: [...media.values()].flat().map((file) => Number(file.id)),
|
||||||
|
});
|
||||||
|
|
||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
const id = Number(message.id);
|
const id = Number(message.id);
|
||||||
const files = media.get(id);
|
const files = media.get(id);
|
||||||
@@ -69,7 +77,14 @@ export default async function grabBotMessages({
|
|||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
...(files
|
...(files
|
||||||
? { attachments: files.map((file) => mediaToAttachment({ media: file })) }
|
? {
|
||||||
|
attachments: files.map((file) =>
|
||||||
|
mediaToAttachment({
|
||||||
|
media: file,
|
||||||
|
file_id: file_ids.get(Number(file.id)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(reply_to ? { reply_to } : {}),
|
...(reply_to ? { reply_to } : {}),
|
||||||
...(user_id ? { reactions: reactions.get(id) || [] } : {}),
|
...(user_id ? { reactions: reactions.get(id) || [] } : {}),
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type Params = {
|
|||||||
workspace_id?: number | null;
|
workspace_id?: number | null;
|
||||||
text: string;
|
text: string;
|
||||||
needs_you?: boolean;
|
needs_you?: boolean;
|
||||||
|
/**
|
||||||
|
* Files the bot saved and sent with it (`media` ids): a PDF it made, a
|
||||||
|
* generated image, a document it wrote.
|
||||||
|
*/
|
||||||
|
media_ids?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +30,7 @@ export default async function insertBotMessage({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
text,
|
text,
|
||||||
needs_you,
|
needs_you,
|
||||||
|
media_ids = [],
|
||||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const needs_you_flag = needs_you ? 1 : 0;
|
const needs_you_flag = needs_you ? 1 : 0;
|
||||||
@@ -52,6 +58,13 @@ export default async function insertBotMessage({
|
|||||||
throw new Error(`Failed to save message: ${res.msg}`);
|
throw new Error(`Failed to save message: ${res.msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const media_id of media_ids) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT INTO bot_message_media (created_at, updated_at, message_id, media_id) VALUES (?, ?, ?, ?)`,
|
||||||
|
values: [now, now, id, media_id],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
await BunSQLite.sql({
|
await BunSQLite.sql({
|
||||||
sql: `UPDATE bot_sessions SET last_activity_at = ?, unread = 1 WHERE id = ?`,
|
sql: `UPDATE bot_sessions SET last_activity_at = ?, unread = 1 WHERE id = ?`,
|
||||||
values: [now, session_id],
|
values: [now, session_id],
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOT_FILES, BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||||
import type { BotMessageAttachment, MediaType } from "@/src/types";
|
import type { BotMessageAttachment, MediaType } from "@/src/types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
media: BUN_SQLITE_COBBLE_MEDIA;
|
media: BUN_SQLITE_COBBLE_MEDIA;
|
||||||
|
/**
|
||||||
|
* The bot file it is, when the caller already knows (avoids a lookup
|
||||||
|
* per attachment).
|
||||||
|
*/
|
||||||
|
file_id?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `media` row as a chat attachment: its original name (`title`), type
|
* A `media` row as a chat attachment: its original name (`title`), type
|
||||||
* and the private URLs it's served at.
|
* and the private URLs it's served at. A file saved in a bot's folder
|
||||||
|
* carries its `file_id`, so the chip can open the file's page.
|
||||||
*/
|
*/
|
||||||
export default function mediaToAttachment({ media }: Params): BotMessageAttachment {
|
export default function mediaToAttachment({ media, file_id }: Params): BotMessageAttachment {
|
||||||
const { media_url, media_thumbnail_url } = grabMediaURL({ media: { ...media } });
|
const { media_url, media_thumbnail_url } = grabMediaURL({ media: { ...media } });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -19,5 +26,29 @@ export default function mediaToAttachment({ media }: Params): BotMessageAttachme
|
|||||||
media_type: (media.media_type || "document") as MediaType,
|
media_type: (media.media_type || "document") as MediaType,
|
||||||
url: media_url || "",
|
url: media_url || "",
|
||||||
thumbnail_url: media_thumbnail_url,
|
thumbnail_url: media_thumbnail_url,
|
||||||
|
file_id: file_id || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `bot_files` rows behind a message's media, keyed by media id, so the
|
||||||
|
* attachments can carry their file ids without a query each.
|
||||||
|
*/
|
||||||
|
export async function grabAttachmentFileIds({ media_ids }: { media_ids: number[] }) {
|
||||||
|
const ids = media_ids.filter((id) => id > 0);
|
||||||
|
|
||||||
|
if (!ids.length) return new Map<number, number>();
|
||||||
|
|
||||||
|
const placeholders = ids.map(() => "?").join(", ");
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT id, media_id FROM bot_files WHERE media_id IN (${placeholders})`,
|
||||||
|
values: ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
(res.payload || [])
|
||||||
|
.filter((file) => file.media_id)
|
||||||
|
.map((file) => [Number(file.media_id), Number(file.id)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||||
|
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabUserWorkspace from "./grab-user-workspace";
|
import grabUserWorkspace from "./grab-user-workspace";
|
||||||
@@ -22,6 +23,14 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params)
|
|||||||
throw new AppError(`Workspace not found.`);
|
throw new AppError(`Workspace not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read before the delete: the shared files' rows go with the workspace.
|
||||||
|
*/
|
||||||
|
const file_media_ids = await grabStorageMediaIds({
|
||||||
|
scope: "workspace",
|
||||||
|
owner_id: workspace.id!,
|
||||||
|
}).catch(() => []);
|
||||||
|
|
||||||
const res = await BunSQLite.sql({
|
const res = await BunSQLite.sql({
|
||||||
sql: `DELETE FROM workspaces WHERE id = ?`,
|
sql: `DELETE FROM workspaces WHERE id = ?`,
|
||||||
values: [workspace.id],
|
values: [workspace.id],
|
||||||
@@ -35,5 +44,14 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params)
|
|||||||
await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots' shared folder there goes with it.
|
||||||
|
*/
|
||||||
|
await purgeStorage({
|
||||||
|
scope: "workspace",
|
||||||
|
owner_id: workspace.id!,
|
||||||
|
media_ids: file_media_ids,
|
||||||
|
});
|
||||||
|
|
||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import userAuth from "../../backend/auth/user-auth";
|
import userAuth from "../../backend/auth/user-auth";
|
||||||
import grabSharedPictureMedia from "@/src/functions/backend/db/access/grab-shared-picture-media";
|
import grabSharedPictureMedia from "@/src/functions/backend/db/access/grab-shared-picture-media";
|
||||||
|
import grabBotFileMedia from "@/src/functions/backend/db/bot-files/grab-bot-file-media";
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export default async function handleMediaServer({
|
|||||||
media =
|
media =
|
||||||
media_res.singleRes ||
|
media_res.singleRes ||
|
||||||
(media_id ? await grabSharedPictureMedia({ media_id, user_id: user.id! }) : null) ||
|
(media_id ? await grabSharedPictureMedia({ media_id, user_id: user.id! }) : null) ||
|
||||||
|
(media_id ? await grabBotFileMedia({ media_id, user_id: user.id! }) : null) ||
|
||||||
undefined;
|
undefined;
|
||||||
} else {
|
} else {
|
||||||
const media_res = await BunSQLite.select<
|
const media_res = await BunSQLite.select<
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Params = {
|
|||||||
* audio: free ones often drop it)
|
* audio: free ones often drop it)
|
||||||
* - speech: a text-to-speech model
|
* - speech: a text-to-speech model
|
||||||
* - image: makes images
|
* - image: makes images
|
||||||
|
* - embedding: turns text into vectors (never a chat model)
|
||||||
*/
|
*/
|
||||||
export default function modelServesHarness({ model, harness }: Params) {
|
export default function modelServesHarness({ model, harness }: Params) {
|
||||||
const input = new Set(model.input_modalities);
|
const input = new Set(model.input_modalities);
|
||||||
@@ -39,5 +40,8 @@ export default function modelServesHarness({ model, harness }: Params) {
|
|||||||
|
|
||||||
case "image":
|
case "image":
|
||||||
return output.has("image");
|
return output.has("image");
|
||||||
|
|
||||||
|
case "embedding":
|
||||||
|
return output.has("embeddings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type Params<T extends HarnessCatalogModel> = {
|
|||||||
*
|
*
|
||||||
* Within a group, benchmarked models come first by intelligence index,
|
* Within a group, benchmarked models come first by intelligence index,
|
||||||
* then the rest: full-size before small variants, newest first. None for
|
* then the rest: full-size before small variants, newest first. None for
|
||||||
* a harness that isn't `automatic` (transcription, speech).
|
* a harness that isn't `automatic` (transcription, speech, embeddings).
|
||||||
*/
|
*/
|
||||||
export default function rankAutoModels<T extends HarnessCatalogModel>({
|
export default function rankAutoModels<T extends HarnessCatalogModel>({
|
||||||
models,
|
models,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import grabHarnessAdapter from "../providers/grab-harness-adapter";
|
||||||
|
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
|
||||||
|
import withCandidates from "../resolve/with-candidates";
|
||||||
|
import fitVectorDimensions from "./fit-vector-dimensions";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The texts to embed, in order. Empty strings are embedded as they
|
||||||
|
* are (a provider may refuse them; callers skip empty chunks).
|
||||||
|
*/
|
||||||
|
input: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* Uses this bot's embedding model when it has its own.
|
||||||
|
*/
|
||||||
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmbeddingResult = {
|
||||||
|
/**
|
||||||
|
* One vector per input, `AppData.EmbeddingDimensions` wide, normalized.
|
||||||
|
*/
|
||||||
|
vectors: Float32Array[];
|
||||||
|
/**
|
||||||
|
* The width the model actually returned, before fitting. Recorded so a
|
||||||
|
* change of model is visible.
|
||||||
|
*/
|
||||||
|
model_dimensions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embeds texts with the embedding harness, fitted to the vector table's
|
||||||
|
* fixed width. Throws `HarnessError` when no embedding model is
|
||||||
|
* configured or every candidate failed; callers fall back to text
|
||||||
|
* matching then.
|
||||||
|
*/
|
||||||
|
export default async function embedText({
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<EmbeddingResult> {
|
||||||
|
if (!input.length) return { vectors: [], model_dimensions: 0 };
|
||||||
|
|
||||||
|
const candidates = await resolveHarnessCandidates({
|
||||||
|
harness: "embedding",
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = await withCandidates({
|
||||||
|
harness: "embedding",
|
||||||
|
candidates,
|
||||||
|
run: async (candidate) =>
|
||||||
|
await grabHarnessAdapter({ kind: candidate.provider.kind }).embed({
|
||||||
|
candidate,
|
||||||
|
input,
|
||||||
|
signal: signal || new AbortController().signal,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
vectors: result.vectors.map((vector) => fitVectorDimensions({ vector })),
|
||||||
|
model_dimensions: result.vectors[0]?.length || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same for one text, for a search query.
|
||||||
|
*/
|
||||||
|
export async function embedQuery({
|
||||||
|
text,
|
||||||
|
signal,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Omit<Params, "input"> & { text: string }) {
|
||||||
|
const { vectors } = await embedText({ input: [text], signal, bot_id, workspace_id });
|
||||||
|
|
||||||
|
return vectors[0] as Float32Array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
vector: number[];
|
||||||
|
/**
|
||||||
|
* Defaults to `AppData.EmbeddingDimensions`, the width of the fixed
|
||||||
|
* `bot_file_vectors` table.
|
||||||
|
*/
|
||||||
|
dimensions?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a provider's vector the width the vector table takes: cut when
|
||||||
|
* it's wider, padded with zeros when narrower, then normalized to unit
|
||||||
|
* length so cosine distance means the same for every model.
|
||||||
|
*/
|
||||||
|
export default function fitVectorDimensions({
|
||||||
|
vector,
|
||||||
|
dimensions = AppData.EmbeddingDimensions,
|
||||||
|
}: Params): Float32Array<ArrayBuffer> {
|
||||||
|
const out = new Float32Array(new ArrayBuffer(dimensions * 4));
|
||||||
|
const length = Math.min(vector.length, dimensions);
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const value = Number(vector[i]) || 0;
|
||||||
|
|
||||||
|
out[i] = value;
|
||||||
|
sum += value * value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A zero vector can't be normalized; leave it as zeros (it then
|
||||||
|
* matches nothing).
|
||||||
|
*/
|
||||||
|
const norm = Math.sqrt(sum);
|
||||||
|
|
||||||
|
if (norm > 0) {
|
||||||
|
for (let i = 0; i < dimensions; i++) {
|
||||||
|
out[i] = out[i]! / norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import HarnessError from "../../harness-error";
|
||||||
|
import type { HarnessAdapter } from "../../types";
|
||||||
|
import grabProviderPreset from "../grab-provider-preset";
|
||||||
|
import providerRequest from "./provider-request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embeddings at the provider's `embeddings_path` (the OpenAI `/embeddings`
|
||||||
|
* shape). Batched, since a large file's chunks would otherwise go in one
|
||||||
|
* request the provider may refuse.
|
||||||
|
*/
|
||||||
|
const embed: HarnessAdapter["embed"] = async ({ candidate, input, signal }) => {
|
||||||
|
const { provider, model } = candidate;
|
||||||
|
const preset = grabProviderPreset({ kind: provider.kind });
|
||||||
|
const vectors: number[][] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every OpenAI-compatible API puts embeddings at `/embeddings`.
|
||||||
|
*/
|
||||||
|
const path = "/embeddings";
|
||||||
|
|
||||||
|
for (let start = 0; start < input.length; start += AppData.EmbeddingBatchSize) {
|
||||||
|
const batch = input
|
||||||
|
.slice(start, start + AppData.EmbeddingBatchSize)
|
||||||
|
.map((text) => text.slice(0, AppData.EmbeddingMaxInputChars));
|
||||||
|
|
||||||
|
const res = await providerRequest({
|
||||||
|
provider,
|
||||||
|
path,
|
||||||
|
json: { model: model.slug, input: batch },
|
||||||
|
signal,
|
||||||
|
candidate_key: candidate.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
const items: any[] = Array.isArray(json?.data)
|
||||||
|
? json.data
|
||||||
|
: Array.isArray(json?.embeddings)
|
||||||
|
? json.embeddings
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (items.length != batch.length) {
|
||||||
|
throw new HarnessError({
|
||||||
|
message: `${provider.name || preset.title}: ${items.length} of ${batch.length} texts got a vector.`,
|
||||||
|
retryable: true,
|
||||||
|
candidate_key: candidate.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const vector = Array.isArray(item) ? item : item?.embedding;
|
||||||
|
|
||||||
|
if (!Array.isArray(vector) || !vector.length) {
|
||||||
|
throw new HarnessError({
|
||||||
|
message: `${provider.name || preset.title} sent no embedding back.`,
|
||||||
|
retryable: true,
|
||||||
|
candidate_key: candidate.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
vectors.push(vector.map(Number));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vectors };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default embed;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { HarnessAdapter } from "../../types";
|
import type { HarnessAdapter } from "../../types";
|
||||||
import chat from "./chat";
|
import chat from "./chat";
|
||||||
|
import embed from "./embed";
|
||||||
import generateImage from "./generate-image";
|
import generateImage from "./generate-image";
|
||||||
import listModels from "./list-models";
|
import listModels from "./list-models";
|
||||||
import speak from "./speak";
|
import speak from "./speak";
|
||||||
@@ -15,6 +16,7 @@ const OpenAICompatibleAdapter: HarnessAdapter = {
|
|||||||
transcribe,
|
transcribe,
|
||||||
speak,
|
speak,
|
||||||
generateImage,
|
generateImage,
|
||||||
|
embed,
|
||||||
listModels,
|
listModels,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import copyBotFile from "@/src/functions/backend/db/bot-files/copy-bot-file";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import { fileRef, findNamedScope, type StorageToolContext } from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
file_id: number;
|
||||||
|
scope: string;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a file into another of the bot's directories: the coordinator
|
||||||
|
* bringing a worker's file into its own or into a workspace, or the other
|
||||||
|
* way round. Only available when the run reaches more than one.
|
||||||
|
*/
|
||||||
|
export default function copyFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "copy_file",
|
||||||
|
description: `Copy one of your files into another directory, by its ref id: ${context.scopes
|
||||||
|
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||||
|
.join(", ")}. The copy is separate: deleting one leaves the other.`,
|
||||||
|
schema: z.object({
|
||||||
|
file_id: z.number().int().positive(),
|
||||||
|
scope: z.string().describe(`Where the copy goes, e.g. "bot:2" or "workspace:1"`),
|
||||||
|
path: z.string().max(AppData.BotFilePathMaxChars).optional(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const target = findNamedScope({ scopes: context.scopes, name: args.scope });
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return `You can't write there. Your directories: ${context.scopes
|
||||||
|
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||||
|
.join(", ")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await copyBotFile({
|
||||||
|
scopes: context.scopes,
|
||||||
|
file_id: args.file_id,
|
||||||
|
bot_id: context.bot_id,
|
||||||
|
scope: target,
|
||||||
|
path: args.path,
|
||||||
|
user_id: context.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Copied to ${target.scope} ${target.owner_id} as ${file.path}. ${fileRef({ file_id: file.id! })}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import deleteBotFile from "@/src/functions/backend/db/bot-files/delete-bot-file";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import type { StorageToolContext } from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
file_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file the bot saved, with its searchable text. Its bytes go for
|
||||||
|
* good, so a file the user may still want is moved, not deleted.
|
||||||
|
*/
|
||||||
|
export default function deleteFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "delete_file",
|
||||||
|
description: `Delete one of your files for good, by its ref id. Use it to clear space, never for something the user still needs.`,
|
||||||
|
schema: z.object({
|
||||||
|
file_id: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const file = await deleteBotFile({ scopes: context.scopes, file_id: args.file_id });
|
||||||
|
|
||||||
|
return `${file.path} is deleted.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
|
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
|
||||||
|
import generateImage from "@/src/harness/image/generate-image";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import {
|
||||||
|
describeScope,
|
||||||
|
fileRef,
|
||||||
|
findNamedScope,
|
||||||
|
formatBytes,
|
||||||
|
type StorageToolContext,
|
||||||
|
} from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
prompt: string;
|
||||||
|
path?: string;
|
||||||
|
size?: string;
|
||||||
|
attach?: boolean;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes an image with the image generation harness and saves it in the
|
||||||
|
* bot's files, so it can be sent, shown again later or used by another
|
||||||
|
* run. The bytes stay in the app's storage, never on a computer.
|
||||||
|
*/
|
||||||
|
export default function generateImageTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
const own = context.scopes[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "generate_image",
|
||||||
|
description: `Make an image from a description and save it in your files. Describe what should be in it, in one clear prompt. The image is saved (so you can send it now and show it later) and can ride on this reply with "attach".`,
|
||||||
|
schema: z.object({
|
||||||
|
prompt: z.string().min(3).max(4000),
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.max(300)
|
||||||
|
.optional()
|
||||||
|
.describe(`Where to save it, e.g. "images/cover.png"; one is named for you otherwise`),
|
||||||
|
size: z
|
||||||
|
.string()
|
||||||
|
.max(20)
|
||||||
|
.optional()
|
||||||
|
.describe(`e.g. "1024x1024", when the model takes one`),
|
||||||
|
attach: z.boolean().optional().describe(`Send it to the user with this reply`),
|
||||||
|
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>"`),
|
||||||
|
}),
|
||||||
|
run: async ({ args, signal }) => {
|
||||||
|
const named = args.scope
|
||||||
|
? findNamedScope({ scopes: context.scopes, name: args.scope })
|
||||||
|
: own;
|
||||||
|
|
||||||
|
if (!named) {
|
||||||
|
return `You can't save there. Your directories: ${context.scopes
|
||||||
|
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||||
|
.join(", ")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = await generateImage({
|
||||||
|
prompt: args.prompt,
|
||||||
|
size: args.size,
|
||||||
|
signal,
|
||||||
|
bot_id: context.bot_id,
|
||||||
|
workspace_id: context.workspace_id,
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Image generation isn't working: ${message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!images.length) {
|
||||||
|
return `The image model sent nothing back. Tell the user, and try a simpler prompt.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < images.length; index++) {
|
||||||
|
const image = images[index]!;
|
||||||
|
const ext = image.media_type.includes("png")
|
||||||
|
? "png"
|
||||||
|
: image.media_type.includes("webp")
|
||||||
|
? "webp"
|
||||||
|
: "jpg";
|
||||||
|
const name =
|
||||||
|
args.path && images.length == 1
|
||||||
|
? args.path
|
||||||
|
: `${stripExtension({ path: args.path || "image" })}${images.length > 1 ? `-${index + 1}` : ""}.${ext}`;
|
||||||
|
|
||||||
|
const file = await recordBotFile({
|
||||||
|
bot_id: context.bot_id,
|
||||||
|
scope: named.scope,
|
||||||
|
owner_id: named.owner_id,
|
||||||
|
path: name,
|
||||||
|
data: image.data,
|
||||||
|
purpose: "image",
|
||||||
|
source: "tool",
|
||||||
|
media_type: "image",
|
||||||
|
mime_type: image.media_type,
|
||||||
|
user_id: context.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enqueueFileIngest({ file_id: file.id! });
|
||||||
|
|
||||||
|
if (args.attach && file.media_id) {
|
||||||
|
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
saved.push(
|
||||||
|
`${file.path} (${formatBytes({ bytes: Number(file.size) || 0 })}) ${fileRef({ file_id: file.id! })}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Made and saved ${saved.length} image(s) in ${describeScope(named)}:\n${saved.join("\n")}${
|
||||||
|
args.attach ? "" : "\n\nIt isn't attached to this reply; the user sees it in your files."
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripExtension({ path }: { path: string }) {
|
||||||
|
return path.replace(/\.[a-zA-Z0-9]{1,5}$/, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import generateImageTool from "./generate-image-tool";
|
||||||
|
import grabStorageTools from "./grab-storage-tools";
|
||||||
|
import makePdfTool from "./make-pdf-tool";
|
||||||
|
import storageToolContext from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
session_id?: number | string;
|
||||||
|
/**
|
||||||
|
* Called for a file the bot wrote with "attach" set, so the reply
|
||||||
|
* carries it.
|
||||||
|
*/
|
||||||
|
onAttach?: (params: { media_id: number; name: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tools a bot uses to keep and search files (`src/harness/tools/
|
||||||
|
* storage/`): listing, reading, writing, moving, deleting and searching
|
||||||
|
* its own directory and, in a workspace or as the coordinator, the shared
|
||||||
|
* ones, plus making images and PDFs into them. Every run gets them, so a
|
||||||
|
* chat reply, a task and a scheduled run all work with the same files.
|
||||||
|
*/
|
||||||
|
export default async function grabBotFileTools({
|
||||||
|
bot,
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
session_id,
|
||||||
|
onAttach,
|
||||||
|
}: Params): Promise<HarnessTool[]> {
|
||||||
|
const all_workspace_ids =
|
||||||
|
bot.kind == "coordinator"
|
||||||
|
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived).map((w) => Number(w.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const context = storageToolContext({
|
||||||
|
bot,
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
session_id,
|
||||||
|
all_workspace_ids,
|
||||||
|
onAttach,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...grabStorageTools(context), generateImageTool(context), makePdfTool(context)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import copyFileTool from "./copy-file-tool";
|
||||||
|
import deleteFileTool from "./delete-file-tool";
|
||||||
|
import ingestFileTool from "./ingest-file-tool";
|
||||||
|
import listFilesTool from "./list-files-tool";
|
||||||
|
import moveFileTool from "./move-file-tool";
|
||||||
|
import readFileTool from "./read-file-tool";
|
||||||
|
import searchFilesTool from "./search-files-tool";
|
||||||
|
import type { StorageToolContext } from "./storage-tool-context";
|
||||||
|
import writeFileTool from "./write-file-tool";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file tools a bot works with on every run, bound to its own
|
||||||
|
* directories (and, for the coordinator, every bot's and workspace's).
|
||||||
|
* `copy_file` only shows when the run reaches more than one directory.
|
||||||
|
*/
|
||||||
|
export default function grabStorageTools(context: StorageToolContext): HarnessTool[] {
|
||||||
|
return [
|
||||||
|
listFilesTool(context),
|
||||||
|
readFileTool(context),
|
||||||
|
writeFileTool(context),
|
||||||
|
searchFilesTool(context),
|
||||||
|
moveFileTool(context),
|
||||||
|
deleteFileTool(context),
|
||||||
|
ingestFileTool(context),
|
||||||
|
...(context.scopes.length > 1 ? [copyFileTool(context)] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import ingestBotFile from "@/src/functions/backend/db/bot-files/ingest-bot-file";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import type { StorageToolContext } from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
file_id: number;
|
||||||
|
read_media?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingests a file again: reads its text, chunks it and embeds it, so a
|
||||||
|
* changed or previously failed file is searchable. Images and audio are
|
||||||
|
* only read when asked for, since that costs a model call.
|
||||||
|
*/
|
||||||
|
export default function ingestFileTool(_context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "ingest_file",
|
||||||
|
description: `Make one of your files searchable again (a course correction when search misses something you know is in it), by its ref id. Images and scans are only read when "read_media" is true.`,
|
||||||
|
schema: z.object({
|
||||||
|
file_id: z.number().int().positive(),
|
||||||
|
read_media: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
run: async ({ args, signal }) => {
|
||||||
|
const result = await ingestBotFile({
|
||||||
|
file_id: args.file_id,
|
||||||
|
read_media: args.read_media,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (result.status) {
|
||||||
|
case "ingested":
|
||||||
|
return `Ingested: ${result.chunks} passage(s), searchable now.`;
|
||||||
|
case "unavailable":
|
||||||
|
return `Its text is stored and searchable by words, but no embedding model is set, so meaning search is off.`;
|
||||||
|
case "skipped":
|
||||||
|
return `It's too large to read (over ${Math.round(AppData.IngestMaxBytes / (1024 * 1024))} MB).`;
|
||||||
|
default:
|
||||||
|
return `It has no text to read.`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import listBotFiles from "@/src/functions/backend/db/bot-files/list-bot-files";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import { fileRef, formatBytes, type StorageToolContext } from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
folder?: string;
|
||||||
|
search?: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists a bot's files: one folder's, or everything under it, or whatever
|
||||||
|
* matches a name. Each file comes with its ref so the bot can read it.
|
||||||
|
*/
|
||||||
|
export default function listFilesTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "list_files",
|
||||||
|
description: `List the files you have saved, newest first: one folder's with "folder", everything with "recursive", or the ones whose name or path has "search". Each line has its path, size and ref (e.g. [file:12]) to read it.`,
|
||||||
|
schema: z.object({
|
||||||
|
folder: z.string().max(AppData.BotFilePathMaxChars).optional(),
|
||||||
|
search: z.string().max(120).optional(),
|
||||||
|
recursive: z.boolean().optional(),
|
||||||
|
scope: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(`"bot:<id>" or "workspace:<id>", to look in one of your directories only`),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const folder = args.folder?.trim() || "";
|
||||||
|
|
||||||
|
const files = await listBotFiles({
|
||||||
|
scopes: context.scopes,
|
||||||
|
folder,
|
||||||
|
recursive: args.recursive,
|
||||||
|
search: args.search,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
return folder
|
||||||
|
? `Nothing in ${folder}.`
|
||||||
|
: `You have no files yet. Write one with write_file.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = files.map((file) => {
|
||||||
|
const where = file.scope == "workspace" ? ` (workspace ${file.owner_id})` : "";
|
||||||
|
const ingested = file.ingested_at ? "" : " - not searchable yet";
|
||||||
|
|
||||||
|
return `- ${file.path} - ${formatBytes({ bytes: Number(file.size) || 0 })}${where}${ingested} ${fileRef({ file_id: file.id! })}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const more =
|
||||||
|
files.length >= AppData.BotFileListMaxItems
|
||||||
|
? `\n(Showing the first ${AppData.BotFileListMaxItems}. Narrow it with "folder" or "search".)`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `${files.length} file(s):\n${lines.join("\n")}${more}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
||||||
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
|
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import {
|
||||||
|
describeScope,
|
||||||
|
fileRef,
|
||||||
|
findNamedScope,
|
||||||
|
formatBytes,
|
||||||
|
type StorageToolContext,
|
||||||
|
} from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
path: string;
|
||||||
|
title?: string;
|
||||||
|
content: string;
|
||||||
|
attach?: boolean;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageWidth = 595.28;
|
||||||
|
const PageHeight = 841.89;
|
||||||
|
const Margin = 56;
|
||||||
|
const BodySize = 11;
|
||||||
|
const Leading = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns text into a PDF (headings, paragraphs and bullets) and saves it in
|
||||||
|
* the bot's files. Made in the app with `pdf-lib`, so it needs no
|
||||||
|
* computer.
|
||||||
|
*/
|
||||||
|
export default function makePdfTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
const own = context.scopes[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "make_pdf",
|
||||||
|
description: `Make a PDF from text (markdown: "# " headings, "- " bullets, blank lines between paragraphs) and save it in your files. Send it to the user with "attach".`,
|
||||||
|
schema: z.object({
|
||||||
|
path: z.string().min(1).max(300).describe(`e.g. "reports/summary.pdf"`),
|
||||||
|
title: z.string().max(200).optional(),
|
||||||
|
content: z.string().min(1).max(100_000),
|
||||||
|
attach: z.boolean().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const named = args.scope
|
||||||
|
? findNamedScope({ scopes: context.scopes, name: args.scope })
|
||||||
|
: own;
|
||||||
|
|
||||||
|
if (!named) {
|
||||||
|
return `You can't save there. Your directories: ${context.scopes
|
||||||
|
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||||
|
.join(", ")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await renderPdf({ title: args.title, content: args.content });
|
||||||
|
const path = args.path.toLowerCase().endsWith(".pdf") ? args.path : `${args.path}.pdf`;
|
||||||
|
|
||||||
|
const file = await recordBotFile({
|
||||||
|
bot_id: context.bot_id,
|
||||||
|
scope: named.scope,
|
||||||
|
owner_id: named.owner_id,
|
||||||
|
path,
|
||||||
|
data: bytes,
|
||||||
|
purpose: "pdf",
|
||||||
|
source: "tool",
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
user_id: context.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enqueueFileIngest({ file_id: file.id! });
|
||||||
|
|
||||||
|
if (args.attach && file.media_id) {
|
||||||
|
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Made ${file.path} (${formatBytes({ bytes: bytes.byteLength })}) in ${describeScope(named)}. ${fileRef({ file_id: file.id! })}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays the text out on A4 pages. Fonts are the PDF standard ones, so
|
||||||
|
* nothing has to be embedded.
|
||||||
|
*/
|
||||||
|
async function renderPdf({ title, content }: { title?: string; content: string }) {
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||||
|
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
|
||||||
|
let page = pdf.addPage([PageWidth, PageHeight]);
|
||||||
|
let y = PageHeight - Margin;
|
||||||
|
|
||||||
|
const newPage = () => {
|
||||||
|
page = pdf.addPage([PageWidth, PageHeight]);
|
||||||
|
y = PageHeight - Margin;
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = ({
|
||||||
|
text,
|
||||||
|
size,
|
||||||
|
use_bold,
|
||||||
|
indent = 0,
|
||||||
|
spacing = 0,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
size: number;
|
||||||
|
use_bold?: boolean;
|
||||||
|
indent?: number;
|
||||||
|
spacing?: number;
|
||||||
|
}) => {
|
||||||
|
const used = use_bold ? bold : font;
|
||||||
|
const width = PageWidth - Margin * 2 - indent;
|
||||||
|
const words = text.split(/\s+/);
|
||||||
|
let line = "";
|
||||||
|
|
||||||
|
const emit = (value: string) => {
|
||||||
|
if (y - size < Margin) newPage();
|
||||||
|
|
||||||
|
page.drawText(value, {
|
||||||
|
x: Margin + indent,
|
||||||
|
y: y - size,
|
||||||
|
size,
|
||||||
|
font: used,
|
||||||
|
color: rgb(0.09, 0.09, 0.11),
|
||||||
|
});
|
||||||
|
|
||||||
|
y -= size + 4;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const candidate = line ? `${line} ${word}` : word;
|
||||||
|
|
||||||
|
if (used.widthOfTextAtSize(candidate, size) > width && line) {
|
||||||
|
emit(line);
|
||||||
|
line = word;
|
||||||
|
} else {
|
||||||
|
line = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line) emit(line);
|
||||||
|
|
||||||
|
y -= spacing;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
write({ text: title, size: 20, use_bold: true, spacing: 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of content.replace(/\r\n/g, "\n").split(/\n{2,}/)) {
|
||||||
|
const trimmed = block.trim();
|
||||||
|
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
const level = heading[1]!.length;
|
||||||
|
|
||||||
|
write({
|
||||||
|
text: heading[2]!,
|
||||||
|
size: level == 1 ? 16 : level == 2 ? 14 : 12,
|
||||||
|
use_bold: true,
|
||||||
|
spacing: 8,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of trimmed.split("\n")) {
|
||||||
|
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
||||||
|
|
||||||
|
if (bullet) {
|
||||||
|
write({ text: `- ${bullet[1]}`, size: BodySize, indent: 12 });
|
||||||
|
} else {
|
||||||
|
write({ text: line.trim(), size: BodySize });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
y -= Leading / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(await pdf.save());
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import moveBotFile from "@/src/functions/backend/db/bot-files/move-bot-file";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import { fileRef, type StorageToolContext } from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
file_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames or moves a file into another folder, inside its own directory.
|
||||||
|
*/
|
||||||
|
export default function moveFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "move_file",
|
||||||
|
description: `Rename a file or move it into another folder, by its ref id. The new path stays inside the same directory (yours, or the workspace's).`,
|
||||||
|
schema: z.object({
|
||||||
|
file_id: z.number().int().positive(),
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(AppData.BotFilePathMaxChars)
|
||||||
|
.describe(`The new path, e.g. "archive/q3.md"`),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const file = await moveBotFile({
|
||||||
|
scopes: context.scopes,
|
||||||
|
file_id: args.file_id,
|
||||||
|
path: args.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Moved to ${file.path}. ${fileRef({ file_id: file.id! })}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
import readMedia from "@/src/harness/media/read-media";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import { fileRef, type StorageToolContext } from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
file_id?: number;
|
||||||
|
path?: string;
|
||||||
|
offset?: number;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextExtensions = [
|
||||||
|
"txt",
|
||||||
|
"md",
|
||||||
|
"markdown",
|
||||||
|
"csv",
|
||||||
|
"json",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"xml",
|
||||||
|
"yml",
|
||||||
|
"yaml",
|
||||||
|
"log",
|
||||||
|
"tsv",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one of the bot's files: text as it is (a window at a time when it
|
||||||
|
* is long), an image or audio through the media harness, and a PDF or DOCX
|
||||||
|
* through the text extractor.
|
||||||
|
*/
|
||||||
|
export default function readFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "read_file",
|
||||||
|
description: `Read a file you saved, by its ref id or its path. Text comes back in a window of about ${Math.round(AppData.BotFileReadMaxChars / 1000)}k characters; use "offset" for the next part. Images and audio are read for you, and PDFs and Word documents are turned into text.`,
|
||||||
|
schema: z.object({
|
||||||
|
file_id: z.number().int().positive().optional(),
|
||||||
|
path: z.string().max(AppData.BotFilePathMaxChars).optional(),
|
||||||
|
offset: z.number().int().min(0).optional(),
|
||||||
|
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>", with "path"`),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const [scope_name, owner] = (args.scope || "").split(":");
|
||||||
|
|
||||||
|
const file = await grabBotFile({
|
||||||
|
scopes: context.scopes,
|
||||||
|
file_id: args.file_id,
|
||||||
|
scope: scope_name,
|
||||||
|
owner_id: owner,
|
||||||
|
path: args.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!file?.id || !file.media?.media_write_path) {
|
||||||
|
return `No file matches that. Use list_files to see what you have.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const absolute = `${DATA_DIR}/${file.media.media_write_path}`;
|
||||||
|
const ext = (file.name || "").split(".").pop()?.toLowerCase() || "";
|
||||||
|
|
||||||
|
if (TextExtensions.includes(ext)) {
|
||||||
|
const text = await Bun.file(absolute).text();
|
||||||
|
const offset = args.offset || 0;
|
||||||
|
const window = text.slice(offset, offset + AppData.BotFileReadMaxChars);
|
||||||
|
const end = offset + window.length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${file.path} ${fileRef({ file_id: file.id })}`,
|
||||||
|
window,
|
||||||
|
end < text.length
|
||||||
|
? `\n[Read ${offset}-${end} of ${text.length} characters. Call read_file with offset ${end} for the rest.]`
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await Bun.file(absolute).arrayBuffer());
|
||||||
|
|
||||||
|
const text = await readMedia({
|
||||||
|
data: bytes,
|
||||||
|
media_type: file.mime_type || "application/octet-stream",
|
||||||
|
name: file.name || undefined,
|
||||||
|
bot_id: context.bot_id,
|
||||||
|
workspace_id: context.workspace_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${file.path} ${fileRef({ file_id: file.id })}\n\n${text.slice(0, AppData.BotFileReadMaxChars)}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import searchBotFiles from "@/src/functions/backend/db/bot-files/search-bot-files";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import { fileRef, type StorageToolContext } from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
query: string;
|
||||||
|
file_id?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches the bot's saved files by meaning (through the embedding
|
||||||
|
* vectors), falling back to words, and returns the passages with their
|
||||||
|
* file's ref so the bot can read more.
|
||||||
|
*/
|
||||||
|
export default function searchFilesTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "search_files",
|
||||||
|
description: `Search the files you have saved (documents the user gave you and what you wrote) and get the passages that answer "query", with their file's ref. Search before saying you don't know something that may be in a file, and before writing a document from memory.`,
|
||||||
|
schema: z.object({
|
||||||
|
query: z.string().min(1).max(500),
|
||||||
|
file_id: z.number().int().positive().optional().describe(`Only this file's passages`),
|
||||||
|
limit: z.number().int().min(1).max(20).optional(),
|
||||||
|
}),
|
||||||
|
run: async ({ args, signal }) => {
|
||||||
|
const result = await searchBotFiles({
|
||||||
|
scopes: context.scopes,
|
||||||
|
query: args.query,
|
||||||
|
file_id: args.file_id,
|
||||||
|
limit: args.limit || AppData.RagTopChunks,
|
||||||
|
signal,
|
||||||
|
bot_id: context.bot_id,
|
||||||
|
workspace_id: context.workspace_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.hits.length) return result.note;
|
||||||
|
|
||||||
|
const passages = result.hits.map((hit, index) => {
|
||||||
|
const where = hit.scope == "workspace" ? ` (workspace ${hit.scope})` : "";
|
||||||
|
|
||||||
|
return `## ${index + 1}. ${hit.name}${where} ${fileRef({ file_id: hit.file_id })}\n${hit.text.trim()}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${result.note}\n\n${passages.join("\n\n")}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import grabStorageScopes, { type StorageScope } from "@/src/utils/storage-access";
|
||||||
|
|
||||||
|
export type StorageToolContext = {
|
||||||
|
/**
|
||||||
|
* The user the run is for.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The directories the run may touch, itself first.
|
||||||
|
*/
|
||||||
|
scopes: StorageScope[];
|
||||||
|
/**
|
||||||
|
* The session being answered, when a file can ride on its reply.
|
||||||
|
*/
|
||||||
|
session_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the run is in, for embeddings.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Called with a file's media id when a tool wrote it and the bot asked
|
||||||
|
* for it to be attached to the reply.
|
||||||
|
*/
|
||||||
|
onAttach?: (params: { media_id: number; name: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
session_id?: number | string;
|
||||||
|
/**
|
||||||
|
* Every workspace, for the coordinator (it reaches them all).
|
||||||
|
*/
|
||||||
|
all_workspace_ids?: number[];
|
||||||
|
onAttach?: StorageToolContext["onAttach"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directories one run may touch, from the bot it's for. The
|
||||||
|
* coordinator reaches every bot's and workspace's files; a worker its own,
|
||||||
|
* plus its workspace's shared one while it works there.
|
||||||
|
*/
|
||||||
|
export default function storageToolContext({
|
||||||
|
bot,
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
session_id,
|
||||||
|
all_workspace_ids,
|
||||||
|
onAttach,
|
||||||
|
}: Params): StorageToolContext {
|
||||||
|
return {
|
||||||
|
user_id,
|
||||||
|
bot_id: bot.id || 0,
|
||||||
|
scopes: grabStorageScopes({
|
||||||
|
bot_id: bot.id || 0,
|
||||||
|
bot_kind: bot.kind,
|
||||||
|
workspace_id,
|
||||||
|
all_workspace_ids,
|
||||||
|
}),
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
|
onAttach,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ref the chat renders as a chip, used in tool results so a file the bot
|
||||||
|
* names links to it.
|
||||||
|
*/
|
||||||
|
export function fileRef({ file_id }: { file_id: number | string }) {
|
||||||
|
return `[file:${file_id}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A human size: "12.4 KB".
|
||||||
|
*/
|
||||||
|
export function formatBytes({ bytes }: { bytes: number }) {
|
||||||
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scope label a tool result shows: "your files" or "the workspace's
|
||||||
|
* shared files".
|
||||||
|
*/
|
||||||
|
export function describeScope({ scope, owner_id }: StorageScope) {
|
||||||
|
return scope == "workspace" ? `the workspace's shared files (#${owner_id})` : "your files";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a scope the bot named, e.g. "workspace:3" or "bot:2".
|
||||||
|
*/
|
||||||
|
export function findNamedScope({
|
||||||
|
scopes,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
name?: string | null;
|
||||||
|
}) {
|
||||||
|
if (!name?.trim()) return undefined;
|
||||||
|
|
||||||
|
const [scope, owner] = name.trim().split(":");
|
||||||
|
|
||||||
|
return scopes.find((s) => s.scope == scope && String(s.owner_id) == owner);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { BotFilePurpose } from "@/src/dict/bot-file-dict";
|
||||||
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
|
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import {
|
||||||
|
describeScope,
|
||||||
|
fileRef,
|
||||||
|
findNamedScope,
|
||||||
|
formatBytes,
|
||||||
|
type StorageToolContext,
|
||||||
|
} from "./storage-tool-context";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
scope?: string;
|
||||||
|
attach?: boolean;
|
||||||
|
purpose?: BotFilePurpose;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a text file (markdown, CSV, JSON, code, plain text) into one of
|
||||||
|
* the bot's directories, making folders as needed. The file stays for
|
||||||
|
* later runs and becomes searchable.
|
||||||
|
*/
|
||||||
|
export default function writeFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||||
|
const own = context.scopes[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "write_file",
|
||||||
|
description: `Save a text file for later: markdown, CSV, JSON, code or plain text. Give it a path like "reports/q3.md"; folders are made as needed. Files belong to ${
|
||||||
|
own?.scope == "workspace" ? "the workspace you're working in" : "you"
|
||||||
|
} and stay between runs: write a file before telling the user something is saved, and search_files to find one you wrote before. It is readable and searchable at once.${
|
||||||
|
context.scopes.length > 1
|
||||||
|
? ` "scope" picks a directory: ${context.scopes.map((s) => `${s.scope}:${s.owner_id}`).join(", ")}.`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
schema: z.object({
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(AppData.BotFilePathMaxChars)
|
||||||
|
.describe(`Where it goes, e.g. "notes.md" or "reports/q3.md"`),
|
||||||
|
content: z.string().max(AppData.BotFileWriteMaxChars),
|
||||||
|
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>"`),
|
||||||
|
attach: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(`Also send it to the user with this reply, as an attachment`),
|
||||||
|
purpose: z
|
||||||
|
.enum(["writing", "artifact"])
|
||||||
|
.optional()
|
||||||
|
.describe(`"writing" for notes and text, "artifact" for a finished file the user asked for`),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const named = args.scope ? findNamedScope({ scopes: context.scopes, name: args.scope }) : own;
|
||||||
|
|
||||||
|
if (!named) {
|
||||||
|
return `You can't write there. Your directories: ${context.scopes
|
||||||
|
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||||
|
.join(", ")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await recordBotFile({
|
||||||
|
bot_id: context.bot_id,
|
||||||
|
scope: named.scope,
|
||||||
|
owner_id: named.owner_id,
|
||||||
|
path: args.path,
|
||||||
|
data: args.content,
|
||||||
|
purpose: args.purpose || "writing",
|
||||||
|
source: "bot-write",
|
||||||
|
user_id: context.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enqueueFileIngest({ file_id: file.id! });
|
||||||
|
|
||||||
|
if (args.attach && file.media_id) {
|
||||||
|
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Saved ${file.path} (${formatBytes({ bytes: Number(file.size) || 0 })}) in ${describeScope(named)}. ${fileRef({ file_id: file.id! })}`,
|
||||||
|
"Tell the user the file's name and what's in it.",
|
||||||
|
].join("\n\n");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -203,6 +203,25 @@ export type HarnessImageOutput = {
|
|||||||
media_type: string;
|
media_type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HarnessEmbedParams = {
|
||||||
|
candidate: HarnessCandidate;
|
||||||
|
/**
|
||||||
|
* The texts to embed, in order. Their vectors come back in the same
|
||||||
|
* order, at whatever width the model produces (the caller fits them
|
||||||
|
* to `AppData.EmbeddingDimensions`).
|
||||||
|
*/
|
||||||
|
input: string[];
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HarnessEmbedResult = {
|
||||||
|
vectors: number[][];
|
||||||
|
/**
|
||||||
|
* The provider's usage, when it reports it.
|
||||||
|
*/
|
||||||
|
usage?: HarnessUsage;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the harness talks to one kind of provider. Every call takes the
|
* How the harness talks to one kind of provider. Every call takes the
|
||||||
* candidate it runs on and an abort signal.
|
* candidate it runs on and an abort signal.
|
||||||
@@ -214,6 +233,7 @@ export type HarnessAdapter = {
|
|||||||
generateImage: (
|
generateImage: (
|
||||||
params: HarnessImageParams,
|
params: HarnessImageParams,
|
||||||
) => Promise<{ images: HarnessImageOutput[] }>;
|
) => Promise<{ images: HarnessImageOutput[] }>;
|
||||||
|
embed: (params: HarnessEmbedParams) => Promise<HarnessEmbedResult>;
|
||||||
listModels: (params: {
|
listModels: (params: {
|
||||||
provider: HarnessProviderConfig;
|
provider: HarnessProviderConfig;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
|||||||
const [name, setName] = useState(bot?.name || "");
|
const [name, setName] = useState(bot?.name || "");
|
||||||
const [role, setRole] = useState(bot?.role || "");
|
const [role, setRole] = useState(bot?.role || "");
|
||||||
const [hue, setHue] = useState<BotHue>(
|
const [hue, setHue] = useState<BotHue>(
|
||||||
() => (bot?.hue as BotHue) || pickFreeHue({ taken: crew.map((b) => b.hue) }),
|
() =>
|
||||||
|
(bot?.hue as BotHue) ||
|
||||||
|
pickFreeHue({ taken: crew.map((b) => b.hue) }),
|
||||||
);
|
);
|
||||||
const [system_prompt, setSystemPrompt] = useState(bot?.system_prompt || "");
|
const [system_prompt, setSystemPrompt] = useState(bot?.system_prompt || "");
|
||||||
const [join, setJoin] = useState(!bot && Boolean(workspace?.id));
|
const [join, setJoin] = useState(!bot && Boolean(workspace?.id));
|
||||||
@@ -57,7 +59,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (!window.confirm(bot?.id ? `Save changes to ${bot.name || "this bot"}?` : `Add ${name || "this bot"} to your crew?`)) return;
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
bot?.id
|
||||||
|
? `Save changes to ${bot.name || "this bot"}?`
|
||||||
|
: `Add ${name || "this bot"} to your crew?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@@ -66,13 +75,21 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
|||||||
const res = bot?.id
|
const res = bot?.id
|
||||||
? await postAdminApi<BUN_SQLITE_COBBLE_BOTS>({
|
? await postAdminApi<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
path: "bots/update-bot",
|
path: "bots/update-bot",
|
||||||
body: { bot_id: bot.id, name: name || bot.name, role, hue, system_prompt },
|
body: {
|
||||||
|
bot_id: bot.id,
|
||||||
|
name: name || bot.name,
|
||||||
|
role,
|
||||||
|
hue,
|
||||||
|
system_prompt,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: await postAdminApi<{ bot_id: number }>({
|
: await postAdminApi<{ bot_id: number }>({
|
||||||
path: "bots/create-bot",
|
path: "bots/create-bot",
|
||||||
body: { name, role, hue, system_prompt, workspace_id },
|
body: { name, role, hue, system_prompt, workspace_id },
|
||||||
});
|
});
|
||||||
const bot_id = bot?.id || (res.singleRes as { bot_id?: number } | undefined)?.bot_id;
|
const bot_id =
|
||||||
|
bot?.id ||
|
||||||
|
(res.singleRes as { bot_id?: number } | undefined)?.bot_id;
|
||||||
|
|
||||||
if (!res.success || !bot_id) {
|
if (!res.success || !bot_id) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -91,7 +108,10 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
|||||||
|
|
||||||
if (!image_res?.success) {
|
if (!image_res?.success) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image.");
|
setError(
|
||||||
|
image_res?.msg ||
|
||||||
|
"The bot is saved, but its picture isn't. Try another image.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +182,16 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
|||||||
info="One line, shown under its name."
|
info="One line, shown under its name."
|
||||||
onChange={(e) => setRole(e.target.value)}
|
onChange={(e) => setRole(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<BotHuePicker hue={hue} disabled={loading} onChange={setHue} />
|
<BotHuePicker
|
||||||
|
hue={hue}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={setHue}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
mode="markdown"
|
mode="markdown"
|
||||||
className="h-40"
|
className="h-[300px]"
|
||||||
title="Instructions"
|
title="Instructions"
|
||||||
placeholder="You research topics for the user. Find good sources, read them, and report back with a short summary and links."
|
placeholder="You research topics for the user. Find good sources, read them, and report back with a short summary and links."
|
||||||
content={system_prompt}
|
content={system_prompt}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { BotFileScopesPayload } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number;
|
||||||
|
workspace_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Load the full list only once the modal opens (the page already has
|
||||||
|
* the first page of files).
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation's files: the page's own copy first, then the folder and
|
||||||
|
* search the modal asks for. Saves and deletes refresh it.
|
||||||
|
*/
|
||||||
|
export default function useBotFiles({ bot_id, workspace_id, enabled = true }: Params) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [data, setData] = useState<BotFileScopesPayload | undefined>(
|
||||||
|
pageProps?.bot_files || undefined,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (params?: { folder?: string; search?: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
if (workspace_id) query.set("workspace_id", String(workspace_id));
|
||||||
|
else query.set("bot_id", String(bot_id));
|
||||||
|
|
||||||
|
if (params?.folder) query.set("folder", params.folder);
|
||||||
|
if (params?.search) query.set("search", params.search);
|
||||||
|
|
||||||
|
const res = await fetchApi<{}, APIResponseObject<BotFileScopesPayload>>(
|
||||||
|
`/api/admin/bots/files?${query.toString()}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res?.success || !res.singleRes) {
|
||||||
|
setError(res?.msg || `Couldn't load the files.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(res.singleRes);
|
||||||
|
},
|
||||||
|
[bot_id, workspace_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) load().catch(() => {});
|
||||||
|
}, [enabled, load]);
|
||||||
|
|
||||||
|
return { data, loading, error, reload: load, setData };
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
import Modal from "@moduletrace/twui/elements/Modal";
|
||||||
|
import H2 from "@moduletrace/twui/layout/H2";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
|
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import useBotFiles from "../(hooks)/use-bot-files";
|
||||||
|
import FileRow from "./file-row";
|
||||||
|
import HeaderMenuItem from "./header-menu-item";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||||
|
/**
|
||||||
|
* A row in the conversation header's menu instead of an icon beside it.
|
||||||
|
*/
|
||||||
|
menu?: boolean;
|
||||||
|
on_open?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files a conversation works with: a bot's own folder, or the
|
||||||
|
* workspace's shared one. Each can be opened, read again so it can be
|
||||||
|
* searched, or deleted (a crew manager only). The list loads when the
|
||||||
|
* modal opens, and search goes through the same endpoint, so the page
|
||||||
|
* props stay small.
|
||||||
|
*/
|
||||||
|
export default function BotFilesModal({ bot, workspace, menu, on_open }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [busy_id, setBusyId] = useState<number>();
|
||||||
|
const [status, setStatus] = useState<string>();
|
||||||
|
const { data, loading, error, reload } = useBotFiles({
|
||||||
|
bot_id: Number(bot.id),
|
||||||
|
workspace_id: workspace?.id ? Number(workspace.id) : null,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||||
|
const name = bot.name || "This bot";
|
||||||
|
const title = workspace?.id ? `${workspace.name || "Workspace"} files` : `${name}'s files`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) on_open?.();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const run = async ({
|
||||||
|
url,
|
||||||
|
payload,
|
||||||
|
file_id,
|
||||||
|
done,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
file_id: number;
|
||||||
|
done: string;
|
||||||
|
}) => {
|
||||||
|
setBusyId(file_id);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await fetchApi<{}, APIResponseObject>(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
setBusyId(undefined);
|
||||||
|
setStatus(res?.success ? done : res?.msg || `That didn't work.`);
|
||||||
|
|
||||||
|
if (res?.success) await reload({ search });
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = data?.files || [];
|
||||||
|
const usage = data?.usage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
target={
|
||||||
|
menu ? (
|
||||||
|
<HeaderMenuItem
|
||||||
|
icon={FolderOpen}
|
||||||
|
title="Files"
|
||||||
|
description={title}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HeaderIconButton icon={FolderOpen} title={title} loading={loading} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
className="max-w-xl bg-paper p-6"
|
||||||
|
>
|
||||||
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
|
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{title}</H2>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{workspace?.id
|
||||||
|
? `Files every bot here can read and search. They're kept with the app, not on a computer.`
|
||||||
|
: `What ${name} has saved: documents you sent, files it wrote and images it made. They're kept with the app, not on a computer.${can_manage ? " Deleting one removes it for good." : ""}`}
|
||||||
|
</Span>
|
||||||
|
{usage ? (
|
||||||
|
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||||
|
{`${usage.files} file(s), ${(usage.bytes / (1024 * 1024)).toFixed(1)} MB of ${
|
||||||
|
data?.quota_mb || 0
|
||||||
|
} MB used in this folder.`}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack className="w-full mt-4 gap-2! items-stretch">
|
||||||
|
<Input
|
||||||
|
placeholder="Search files"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key == "Enter") reload({ search }).catch(() => {});
|
||||||
|
}}
|
||||||
|
clearInputProps={{ onClick: () => reload({}).catch(() => {}) }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack className="w-full mt-2 gap-0! items-stretch overflow-y-auto max-h-[50vh]">
|
||||||
|
{files.length ? (
|
||||||
|
files.map((file) => (
|
||||||
|
<FileRow
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
can_manage={can_manage}
|
||||||
|
busy={busy_id == file.id}
|
||||||
|
onIngest={() =>
|
||||||
|
run({
|
||||||
|
url: `/api/admin/bots/ingest-file`,
|
||||||
|
payload: { file_id: file.id },
|
||||||
|
file_id: file.id,
|
||||||
|
done: `${file.name} will be read again shortly.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onDelete={() =>
|
||||||
|
run({
|
||||||
|
url: `/api/admin/bots/delete-file`,
|
||||||
|
payload: { file_id: file.id },
|
||||||
|
file_id: file.id,
|
||||||
|
done: `${file.name} is deleted.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{loading
|
||||||
|
? "Loading files..."
|
||||||
|
: error || (search ? `Nothing matches "${search}".` : "No files yet.")}
|
||||||
|
</Span>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{status ? (
|
||||||
|
<Span role="status" className="mt-3 text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{status}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useContext } from "react";
|
|||||||
import Paper from "@moduletrace/twui/elements/Paper";
|
import Paper from "@moduletrace/twui/elements/Paper";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import Divider from "@moduletrace/twui/layout/Divider";
|
import Divider from "@moduletrace/twui/layout/Divider";
|
||||||
import { History, Settings } from "lucide-react";
|
import { FolderOpen, History, Settings } from "lucide-react";
|
||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||||
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
|
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
|
||||||
@@ -10,6 +10,7 @@ import { AppContext } from "@/src/pages/__root";
|
|||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||||
import BotComputersModal from "./bot-computers-modal";
|
import BotComputersModal from "./bot-computers-modal";
|
||||||
|
import BotFilesModal from "./bot-files-modal";
|
||||||
import BotModelsModal from "./bot-models-modal";
|
import BotModelsModal from "./bot-models-modal";
|
||||||
import HeaderMenuItem from "./header-menu-item";
|
import HeaderMenuItem from "./header-menu-item";
|
||||||
import NewSessionButton from "./new-session-button";
|
import NewSessionButton from "./new-session-button";
|
||||||
@@ -94,6 +95,12 @@ export default function ConversationHeaderMenu({
|
|||||||
workspace_id={workspace?.id || null}
|
workspace_id={workspace?.id || null}
|
||||||
on_open={on_open}
|
on_open={on_open}
|
||||||
/>
|
/>
|
||||||
|
<BotFilesModal
|
||||||
|
menu
|
||||||
|
bot={bot}
|
||||||
|
workspace={workspace}
|
||||||
|
on_open={on_open}
|
||||||
|
/>
|
||||||
<BotComputersModal
|
<BotComputersModal
|
||||||
menu
|
menu
|
||||||
bot={bot}
|
bot={bot}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import Link from "@moduletrace/twui/layout/Link";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { MediaType } from "@/src/types";
|
||||||
|
import AttachmentIcon from "./attachment-icon";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
file_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file a bot named in a message (`fileRef`): the name it is saved under,
|
||||||
|
* linked to its bytes. The conversation's files come with the page, so no
|
||||||
|
* request is made per chip; a file the user doesn't reach (or one deleted
|
||||||
|
* since) shows as its id.
|
||||||
|
*/
|
||||||
|
export default function FileRefChip({ file_id }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const file = pageProps?.bot_files?.files?.find((it) => it.id == file_id);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return <Span className="font-bold">{`file ${file_id}`}</Span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Open ${file.name}`}
|
||||||
|
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink hover:opacity-80"
|
||||||
|
>
|
||||||
|
<AttachmentIcon media_type={file.media_type as MediaType} />
|
||||||
|
<Span className="truncate">{file.name}</Span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from "@moduletrace/twui/layout/Link";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import { RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import type { MediaType, PublicBotFile } from "@/src/types";
|
||||||
|
import relativeTime from "@/src/utils/relative-time";
|
||||||
|
import AttachmentIcon from "./attachment-icon";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
file: PublicBotFile;
|
||||||
|
/**
|
||||||
|
* A crew manager may delete and re-ingest.
|
||||||
|
*/
|
||||||
|
can_manage: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
onIngest: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One file in the files modal: its type, path, size and age, and what can
|
||||||
|
* be done with it. A file that isn't searchable says why.
|
||||||
|
*/
|
||||||
|
export default function FileRow({ file, can_manage, busy, onIngest, onDelete }: Props) {
|
||||||
|
return (
|
||||||
|
<Row className="w-full gap-3! flex-nowrap items-start py-2 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||||
|
<Row className="mt-0.5 text-ink-secondary">
|
||||||
|
<AttachmentIcon media_type={file.media_type as MediaType} size={16} />
|
||||||
|
</Row>
|
||||||
|
<Stack className="min-w-0 flex-1 gap-0.5!">
|
||||||
|
<Link
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Open ${file.name}`}
|
||||||
|
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
|
||||||
|
>
|
||||||
|
{file.path || file.name}
|
||||||
|
</Link>
|
||||||
|
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||||
|
{[
|
||||||
|
file.scope == "workspace" ? "Workspace" : "Bot files",
|
||||||
|
`${Math.max(1, Math.round(file.size / 1024))} KB`,
|
||||||
|
file.created_at ? relativeTime({ at: file.created_at }) : "",
|
||||||
|
file.ingest_error
|
||||||
|
? file.ingest_error
|
||||||
|
: file.ingested_at
|
||||||
|
? "searchable"
|
||||||
|
: "not searchable yet",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
{can_manage ? (
|
||||||
|
<Row className="shrink-0 gap-0!">
|
||||||
|
<Button
|
||||||
|
title="Read it again so it can be searched"
|
||||||
|
aria-label="Reingest"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onIngest}
|
||||||
|
className="p-1.5!"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
title="Delete it"
|
||||||
|
aria-label="Delete"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1.5!"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-di
|
|||||||
import {
|
import {
|
||||||
BotRefHrefPrefix,
|
BotRefHrefPrefix,
|
||||||
ComputerRefHrefPrefix,
|
ComputerRefHrefPrefix,
|
||||||
|
FileRefHrefPrefix,
|
||||||
TakeOverRefHrefPrefix,
|
TakeOverRefHrefPrefix,
|
||||||
WorkspaceRefHrefPrefix,
|
WorkspaceRefHrefPrefix,
|
||||||
} from "@/src/utils/link-computer-refs";
|
} from "@/src/utils/link-computer-refs";
|
||||||
import BotRefChip from "./bot-ref-chip";
|
import BotRefChip from "./bot-ref-chip";
|
||||||
import ComputerRefChip from "./computer-ref-chip";
|
import ComputerRefChip from "./computer-ref-chip";
|
||||||
|
import FileRefChip from "./file-ref-chip";
|
||||||
import TakeOverChip from "./take-over-chip";
|
import TakeOverChip from "./take-over-chip";
|
||||||
import WorkspaceRefChip from "./workspace-ref-chip";
|
import WorkspaceRefChip from "./workspace-ref-chip";
|
||||||
|
|
||||||
@@ -16,7 +18,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
|||||||
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
|
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
|
||||||
* becomes its switch button, a take-over request a button that opens the
|
* becomes its switch button, a take-over request a button that opens the
|
||||||
* computer, a bot it named a link to that bot's conversation, a workspace
|
* computer, a bot it named a link to that bot's conversation, a workspace
|
||||||
* a link to that workspace; anything else opens in a new tab.
|
* a link to that workspace, a file it saved a chip that opens it; anything
|
||||||
|
* else opens in a new tab.
|
||||||
*/
|
*/
|
||||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||||
if (href?.startsWith(ComputerRefHrefPrefix)) {
|
if (href?.startsWith(ComputerRefHrefPrefix)) {
|
||||||
@@ -52,6 +55,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
|||||||
if (workspace_id) return <WorkspaceRefChip workspace_id={workspace_id} />;
|
if (workspace_id) return <WorkspaceRefChip workspace_id={workspace_id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (href?.startsWith(FileRefHrefPrefix)) {
|
||||||
|
const file_id = Number(href.slice(FileRefHrefPrefix.length));
|
||||||
|
|
||||||
|
if (file_id) return <FileRefChip file_id={file_id} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
|
|||||||
@@ -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 grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-password-hash";
|
||||||
import type { PagePropsType } from "@/src/types";
|
import type { PagePropsType } from "@/src/types";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||||
|
|
||||||
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
|
|||||||
props: {
|
props: {
|
||||||
person: sanitizeUserBio({ user: user_res.singleRes }),
|
person: sanitizeUserBio({ user: user_res.singleRes }),
|
||||||
has_password,
|
has_password,
|
||||||
|
storage_quota: await grabStorageQuotaOverview().catch(() => null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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 Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import type { StorageQuotaOverview } from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||||
|
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
storage?: StorageQuotaOverview | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-folder storage quota for what bots save (a bot's own folder and
|
||||||
|
* each workspace's shared one). Saved on its own, without a reload.
|
||||||
|
*/
|
||||||
|
export default function StorageQuotaForm({ storage }: Props) {
|
||||||
|
const [overview, setOverview] = useState(storage || undefined);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [value, setValue] = useState(String(storage?.quota_mb ?? ""));
|
||||||
|
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<StorageQuotaOverview>>(
|
||||||
|
"/api/admin/settings/storage-quota",
|
||||||
|
{ method: "POST", body: { quota_mb: Number(value) || 0 } },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res?.success || !res.singleRes) {
|
||||||
|
setStatus({ error: true, msg: res?.msg || `Couldn't save the quota.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverview(res.singleRes);
|
||||||
|
setValue(String(res.singleRes.quota_mb));
|
||||||
|
setDirty(false);
|
||||||
|
setStatus({ success: true, msg: `Saved: ${res.singleRes.quota_mb} MB per folder.` });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
save().catch(() => {});
|
||||||
|
}}
|
||||||
|
className="w-full gap-3! items-stretch"
|
||||||
|
>
|
||||||
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
|
<Input
|
||||||
|
label="Megabytes per folder"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
setStatus(undefined);
|
||||||
|
}}
|
||||||
|
info="A bot's own folder and each workspace's folder each get this much. A write past it is refused, so the bot clears space first."
|
||||||
|
/>
|
||||||
|
{overview ? (
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{`${overview.total_files} file(s) in ${overview.folders} folder(s), ${(overview.total_bytes / (1024 * 1024)).toFixed(1)} MB in total.`}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<SettingsSaveBar
|
||||||
|
dirty={dirty}
|
||||||
|
loading={loading}
|
||||||
|
status={status}
|
||||||
|
save_label="Save quota"
|
||||||
|
onDiscard={() => {
|
||||||
|
setValue(String(overview?.quota_mb ?? ""));
|
||||||
|
setDirty(false);
|
||||||
|
setStatus(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
|
import StorageQuotaForm from "../(partials)/storage-quota-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What bots keep on disk: the quota each of their folders gets. Only a
|
||||||
|
* crew manager sees it, since it's an install-wide limit.
|
||||||
|
*/
|
||||||
|
export default function StorageSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
|
||||||
|
if (!canManageCrew({ user_types: pageProps?.user_types })) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
title="Bot files Quota"
|
||||||
|
description="Bots keep the documents, images and reports they make in folders on the app's data volume, never on a computer. This is how much each folder may hold."
|
||||||
|
id="storage"
|
||||||
|
>
|
||||||
|
<StorageQuotaForm storage={pageProps?.storage_quota} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppearanceSection from "./(sections)/appearance-section";
|
import AppearanceSection from "./(sections)/appearance-section";
|
||||||
|
import StorageSection from "./(sections)/storage-section";
|
||||||
import SettingsShell from "../(partials)/settings-shell";
|
import SettingsShell from "../(partials)/settings-shell";
|
||||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export default function AdminSettingsGeneralPage() {
|
|||||||
description="How the app looks and behaves."
|
description="How the app looks and behaves."
|
||||||
>
|
>
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
<StorageSection />
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||||
|
import deleteBotFile from "@/src/functions/backend/db/bot-files/delete-bot-file";
|
||||||
|
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||||
|
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { DeleteBotFileRequestSchema } from "@/src/schemas/bot-file-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one of a conversation's files. A crew manager only, and never a
|
||||||
|
* read-only user.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.id) return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
if (!canManageCrew({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`Only a crew manager can delete files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { file_id } = parseWithSchema({ schema: DeleteBotFileRequestSchema, data: body });
|
||||||
|
|
||||||
|
const file = await grabBotFile({
|
||||||
|
scopes: await reachableScopes({ user_id: user.id, file_id }),
|
||||||
|
file_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!file?.id) throw new AppError(`That file isn't here.`);
|
||||||
|
|
||||||
|
await deleteBotFile({
|
||||||
|
scopes: [{ scope: file.scope as any, owner_id: Number(file.owner_id) }],
|
||||||
|
file_id: file.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Bot file deleted",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} deleted ${file.path} from ${file.scope} ${file.owner_id}.`,
|
||||||
|
metadata: { file_id: file.id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, msg: `${file.name || "The file"} is deleted.` };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directories the user reaches: every worker's for a crew manager,
|
||||||
|
* the coordinator's for the super admin, and every workspace it's in.
|
||||||
|
*/
|
||||||
|
async function reachableScopes({
|
||||||
|
user_id,
|
||||||
|
file_id,
|
||||||
|
}: {
|
||||||
|
user_id: number;
|
||||||
|
file_id: number;
|
||||||
|
}): Promise<StorageScope[]> {
|
||||||
|
const { default: BunSQLite } = await import("@moduletrace/bun-sqlite");
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<{ scope: string; owner_id: number }>({
|
||||||
|
sql: `SELECT scope, owner_id FROM bot_files WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = res.payload?.[0];
|
||||||
|
|
||||||
|
if (!file?.owner_id) return [];
|
||||||
|
|
||||||
|
if (file.scope == "workspace") {
|
||||||
|
const workspace = await grabUserWorkspace({ workspace_id: file.owner_id, user_id });
|
||||||
|
|
||||||
|
return workspace?.id ? [{ scope: "workspace", owner_id: Number(file.owner_id) }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await grabUserBot({ bot_id: file.owner_id, user_id });
|
||||||
|
|
||||||
|
return bot?.id ? [{ scope: "bot", owner_id: Number(file.owner_id) }] : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||||
|
import grabBotFileQuotaBytes, {
|
||||||
|
grabBotFileQuotaMb,
|
||||||
|
} from "@/src/functions/backend/db/bot-files/grab-bot-file-quota-bytes";
|
||||||
|
import grabStorageUsage from "@/src/functions/backend/db/bot-files/grab-storage-usage";
|
||||||
|
import listBotFiles, { listBotFolders } from "@/src/functions/backend/db/bot-files/list-bot-files";
|
||||||
|
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||||
|
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||||
|
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||||
|
import type { BotFileScopesPayload } from "@/src/types";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { BotFileScope } from "@/src/dict/bot-file-dict";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation's files: the bot's own, or a workspace's shared ones.
|
||||||
|
* `?file_id=` returns one (for a message's file chip), else a folder's
|
||||||
|
* files. Only a user who reaches the bot or workspace sees them.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BotFileScopesPayload>> = async ({
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "GET") {
|
||||||
|
return { success: false, msg: `Method not allowed` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const bot_id = Number(url.searchParams.get("bot_id"));
|
||||||
|
const workspace_id = Number(url.searchParams.get("workspace_id"));
|
||||||
|
const file_id = Number(url.searchParams.get("file_id"));
|
||||||
|
const folder = url.searchParams.get("folder") || "";
|
||||||
|
const search = url.searchParams.get("search") || "";
|
||||||
|
|
||||||
|
const scope: { scope: BotFileScope; owner_id: number } | null = workspace_id
|
||||||
|
? (await grabUserWorkspace({ workspace_id, user_id: user.id }))
|
||||||
|
? { scope: "workspace", owner_id: workspace_id }
|
||||||
|
: null
|
||||||
|
: bot_id
|
||||||
|
? (await grabUserBot({ bot_id, user_id: user.id }))
|
||||||
|
? { scope: "bot", owner_id: bot_id }
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!scope) {
|
||||||
|
return { success: false, msg: `Not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes = [scope];
|
||||||
|
|
||||||
|
if (file_id) {
|
||||||
|
const file = await grabBotFile({ scopes, file_id });
|
||||||
|
|
||||||
|
if (!file?.id) return { success: false, msg: `Not found.` };
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
singleRes: {
|
||||||
|
files: [toPublicFile({ file })],
|
||||||
|
folders: [],
|
||||||
|
usage: { bytes: 0, files: 0 },
|
||||||
|
quota_bytes: 0,
|
||||||
|
quota_mb: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [files, folders, usage, quota_bytes, quota_mb] = await Promise.all([
|
||||||
|
listBotFiles({ scopes, folder, search }),
|
||||||
|
listBotFolders({ scopes }),
|
||||||
|
grabStorageUsage(scope),
|
||||||
|
grabBotFileQuotaBytes(),
|
||||||
|
grabBotFileQuotaMb(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
singleRes: {
|
||||||
|
files: files.map((file) => toPublicFile({ file })),
|
||||||
|
folders,
|
||||||
|
usage,
|
||||||
|
quota_bytes,
|
||||||
|
quota_mb,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function toPublicFile({ file }: { file: BotFileWithMediaLite }) {
|
||||||
|
const { media_url } = grabMediaURL({ media: { ...(file.media || {}), id: file.media_id } as any });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(file.id),
|
||||||
|
name: file.name || "",
|
||||||
|
path: file.path || "",
|
||||||
|
size: Number(file.size) || 0,
|
||||||
|
media_type: file.media_type || "document",
|
||||||
|
purpose: file.purpose || "writing",
|
||||||
|
scope: file.scope || "bot",
|
||||||
|
owner_id: Number(file.owner_id) || 0,
|
||||||
|
ingested_at: file.ingested_at ? Number(file.ingested_at) : undefined,
|
||||||
|
ingest_error: file.ingest_error || undefined,
|
||||||
|
created_at: Number(file.created_at) || 0,
|
||||||
|
url: media_url || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type BotFileWithMediaLite = {
|
||||||
|
id?: number | "";
|
||||||
|
media_id?: number | "";
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
size?: number | "";
|
||||||
|
media_type?: string;
|
||||||
|
purpose?: string;
|
||||||
|
scope?: string;
|
||||||
|
owner_id?: number | "";
|
||||||
|
ingested_at?: number | "";
|
||||||
|
ingest_error?: string;
|
||||||
|
created_at?: number | "";
|
||||||
|
media?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||||
|
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||||
|
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||||
|
import { IngestBotFileRequestSchema } from "@/src/schemas/bot-file-schema";
|
||||||
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a conversation's file to be read again and re-embedded, for when
|
||||||
|
* a search misses something the user knows is in it.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.id) return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
if (!canManageCrew({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`Only a crew manager can re-read files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { file_id, read_media } = parseWithSchema({
|
||||||
|
schema: IngestBotFileRequestSchema,
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopes = await reachableScopes({ user_id: user.id, file_id });
|
||||||
|
|
||||||
|
const file = await grabBotFile({ scopes, file_id });
|
||||||
|
|
||||||
|
if (!file?.id) throw new AppError(`That file isn't here.`);
|
||||||
|
|
||||||
|
await enqueueFileIngest({ file_id: file.id });
|
||||||
|
|
||||||
|
return { success: true, msg: `${file.name || "The file"} will be read again shortly.` };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directories the user reaches for this file.
|
||||||
|
*/
|
||||||
|
async function reachableScopes({
|
||||||
|
user_id,
|
||||||
|
file_id,
|
||||||
|
}: {
|
||||||
|
user_id: number;
|
||||||
|
file_id: number;
|
||||||
|
}): Promise<StorageScope[]> {
|
||||||
|
const { default: BunSQLite } = await import("@moduletrace/bun-sqlite");
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<{ scope: string; owner_id: number }>({
|
||||||
|
sql: `SELECT scope, owner_id FROM bot_files WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = res.payload?.[0];
|
||||||
|
|
||||||
|
if (!file?.owner_id) return [];
|
||||||
|
|
||||||
|
if (file.scope == "workspace") {
|
||||||
|
const workspace = await grabUserWorkspace({ workspace_id: file.owner_id, user_id });
|
||||||
|
|
||||||
|
return workspace?.id ? [{ scope: "workspace", owner_id: Number(file.owner_id) }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await grabUserBot({ bot_id: file.owner_id, user_id });
|
||||||
|
|
||||||
|
return bot?.id ? [{ scope: "bot", owner_id: Number(file.owner_id) }] : [];
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot
|
|||||||
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
|
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
|
||||||
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
|
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
|
||||||
import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media";
|
import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media";
|
||||||
|
import storeChatAttachments from "@/src/functions/backend/db/bot-files/store-chat-attachments";
|
||||||
import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
|
import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
|
||||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||||
import wakeBotsQueue from "@/src/bots-queue/wake-bots-queue";
|
import wakeBotsQueue from "@/src/bots-queue/wake-bots-queue";
|
||||||
@@ -93,6 +94,17 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
|||||||
media,
|
media,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files join the conversation's folder, so a later run can
|
||||||
|
* search what the user sent now.
|
||||||
|
*/
|
||||||
|
await storeChatAttachments({
|
||||||
|
bot_id: bot.id,
|
||||||
|
workspace_id: conversation.workspace_id,
|
||||||
|
user_id: user.id,
|
||||||
|
media,
|
||||||
|
});
|
||||||
|
|
||||||
await enqueueBotReply({
|
await enqueueBotReply({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import grabStorageQuotaOverview, {
|
||||||
|
type StorageQuotaOverview,
|
||||||
|
} from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||||
|
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { BotFileQuotaSettingKey } from "@/src/dict/bot-file-dict";
|
||||||
|
import { SaveStorageQuotaRequestSchema } from "@/src/schemas/bot-file-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: the per-folder quota for what bots save, in MB. Zero
|
||||||
|
* puts the code's default back.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<StorageQuotaOverview>> = 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 storage quota.`);
|
||||||
|
|
||||||
|
const { quota_mb } = parseWithSchema({
|
||||||
|
schema: SaveStorageQuotaRequestSchema,
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveAppSetting({
|
||||||
|
key: BotFileQuotaSettingKey,
|
||||||
|
value: quota_mb > 0 ? String(quota_mb) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Bot file quota changed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} set the bot file quota to ${quota_mb} MB per folder.`,
|
||||||
|
metadata: { quota_mb },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, singleRes: await grabStorageQuotaOverview() };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one of a conversation's files.
|
||||||
|
*/
|
||||||
|
export const DeleteBotFileRequestSchema = z.object({
|
||||||
|
file_id: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one file again so it can be searched.
|
||||||
|
*/
|
||||||
|
export const IngestBotFileRequestSchema = z.object({
|
||||||
|
file_id: z.coerce.number().int().positive(),
|
||||||
|
read_media: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The General settings' storage quota, in MB.
|
||||||
|
*/
|
||||||
|
export const SaveStorageQuotaRequestSchema = z.object({
|
||||||
|
quota_mb: z.coerce.number().int().min(0).max(1_000_000),
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import userAuth from "./functions/backend/auth/user-auth";
|
|||||||
import type { WebSocketSession } from "./websocket/types";
|
import type { WebSocketSession } from "./websocket/types";
|
||||||
import { logError, grabRequestIP } from "./functions/backend/logger";
|
import { logError, grabRequestIP } from "./functions/backend/logger";
|
||||||
import botsQueue from "./bots-queue";
|
import botsQueue from "./bots-queue";
|
||||||
|
import fileIngest from "./file-ingest";
|
||||||
import liveRelay from "./live-relay";
|
import liveRelay from "./live-relay";
|
||||||
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
||||||
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
|
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
|
||||||
@@ -164,6 +165,9 @@ try {
|
|||||||
botsQueue().catch((error) =>
|
botsQueue().catch((error) =>
|
||||||
logError({ title: "Bots queue couldn't start", error }).catch(() => {}),
|
logError({ title: "Bots queue couldn't start", error }).catch(() => {}),
|
||||||
);
|
);
|
||||||
|
fileIngest().catch((error) =>
|
||||||
|
logError({ title: "File ingest couldn't start", error }).catch(() => {}),
|
||||||
|
);
|
||||||
liveRelay().catch((error) =>
|
liveRelay().catch((error) =>
|
||||||
logError({ title: "Live relay couldn't start", error }).catch(() => {}),
|
logError({ title: "Live relay couldn't start", error }).catch(() => {}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -622,6 +622,38 @@ export type EnvsOverviewPayload = {
|
|||||||
known: KnownEnvStatus[];
|
known: KnownEnvStatus[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot file as the chat's files modal lists it.
|
||||||
|
*/
|
||||||
|
export type PublicBotFile = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
media_type: string;
|
||||||
|
purpose: string;
|
||||||
|
scope: string;
|
||||||
|
owner_id: number;
|
||||||
|
ingested_at?: number;
|
||||||
|
ingest_error?: string;
|
||||||
|
created_at: number;
|
||||||
|
/**
|
||||||
|
* Where the bytes are served (`/media/private/<media id>`).
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation's files, with the directory's usage against its quota.
|
||||||
|
*/
|
||||||
|
export type BotFileScopesPayload = {
|
||||||
|
files: PublicBotFile[];
|
||||||
|
folders: { scope: string; owner_id: number; path: string }[];
|
||||||
|
usage: { bytes: number; files: number };
|
||||||
|
quota_bytes: number;
|
||||||
|
quota_mb: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A worker bot's model settings page data, also returned when it saves.
|
* A worker bot's model settings page data, also returned when it saves.
|
||||||
*/
|
*/
|
||||||
@@ -634,6 +666,10 @@ export type BotMessageAttachment = {
|
|||||||
media_type: MediaType;
|
media_type: MediaType;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
|
/**
|
||||||
|
* The bot file it is, when it was saved in one of the bot's folders.
|
||||||
|
*/
|
||||||
|
file_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -794,10 +830,26 @@ export type PagePropsType = {
|
|||||||
harness_overview?: HarnessOverviewPayload | null;
|
harness_overview?: HarnessOverviewPayload | null;
|
||||||
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
||||||
envs_overview?: EnvsOverviewPayload | null;
|
envs_overview?: EnvsOverviewPayload | null;
|
||||||
|
/**
|
||||||
|
* What bots keep on disk: the per-folder quota and its usage (the
|
||||||
|
* General settings page).
|
||||||
|
*/
|
||||||
|
storage_quota?: {
|
||||||
|
setting_key: string;
|
||||||
|
quota_mb: number;
|
||||||
|
total_bytes: number;
|
||||||
|
total_files: number;
|
||||||
|
folders: number;
|
||||||
|
} | null;
|
||||||
push_overview?: PushOverview | null;
|
push_overview?: PushOverview | null;
|
||||||
mcps_overview?: McpServersOverviewPayload | null;
|
mcps_overview?: McpServersOverviewPayload | null;
|
||||||
tools_overview?: CustomToolsOverviewPayload | null;
|
tools_overview?: CustomToolsOverviewPayload | null;
|
||||||
bot_extensions?: BotExtensionsPayload | null;
|
bot_extensions?: BotExtensionsPayload | null;
|
||||||
|
/**
|
||||||
|
* The open conversation's files (a bot's own, or a workspace's shared
|
||||||
|
* ones), for the files modal and the chips that name one.
|
||||||
|
*/
|
||||||
|
bot_files?: BotFileScopesPayload | null;
|
||||||
/**
|
/**
|
||||||
* Whether the open conversation's mic (transcription) and read-aloud
|
* Whether the open conversation's mic (transcription) and read-aloud
|
||||||
* (speech) are turned on for the bot there.
|
* (speech) are turned on for the bot there.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import fileRef, { fileRefId } from "./file-ref";
|
||||||
|
import linkComputerRefs from "./link-computer-refs";
|
||||||
|
|
||||||
|
describe("fileRef", () => {
|
||||||
|
test("writes the ref the chat links", () => {
|
||||||
|
expect(fileRef({ id: 12 })).toBe("[file:12]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads its id back", () => {
|
||||||
|
expect(fileRefId({ href: "#file-12" })).toBe(12);
|
||||||
|
expect(fileRefId({ href: "#file-0" })).toBeNull();
|
||||||
|
expect(fileRefId({ href: "#bot-3" })).toBeNull();
|
||||||
|
expect(fileRefId({ href: undefined })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linkComputerRefs", () => {
|
||||||
|
test("turns a file ref into a link", () => {
|
||||||
|
expect(linkComputerRefs({ text: "Saved [file:12] there." })).toBe(
|
||||||
|
"Saved [file 12](#file-12) there.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves an existing markdown link alone", () => {
|
||||||
|
expect(linkComputerRefs({ text: "[file:12](#file-12)" })).toBe("[file:12](#file-12)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still links the other refs", () => {
|
||||||
|
const text = linkComputerRefs({ text: "[bot:3] [workspace:2] [computer:5]" });
|
||||||
|
|
||||||
|
expect(text).toContain("(#bot-3)");
|
||||||
|
expect(text).toContain("(#workspace-2)");
|
||||||
|
expect(text).toContain("(#computer-5)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* How a bot names one of its files in a message: `[file:12]`. The chat
|
||||||
|
* turns it into a link to the file (`linkComputerRefs`), which a chip
|
||||||
|
* renders with the file's name.
|
||||||
|
*/
|
||||||
|
export default function fileRef({ id }: { id: number | string }) {
|
||||||
|
return `[file:${id}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link target a file ref becomes, e.g. `#file-12`.
|
||||||
|
*/
|
||||||
|
export const FileRefHrefPrefix = "#file-";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id in a file ref's link target, or null.
|
||||||
|
*/
|
||||||
|
export function fileRefId({ href }: { href?: string | null }) {
|
||||||
|
if (!href?.startsWith(FileRefHrefPrefix)) return null;
|
||||||
|
|
||||||
|
const id = Number(href.slice(FileRefHrefPrefix.length));
|
||||||
|
|
||||||
|
return Number.isFinite(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
@@ -24,11 +24,18 @@ export const BotRefHrefPrefix = "#bot-";
|
|||||||
*/
|
*/
|
||||||
export const WorkspaceRefHrefPrefix = "#workspace-";
|
export const WorkspaceRefHrefPrefix = "#workspace-";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link target a file ref (`fileRef`) becomes, e.g. `#file-12`. The
|
||||||
|
* chat renders these as a chip that opens the file.
|
||||||
|
*/
|
||||||
|
export const FileRefHrefPrefix = "#file-";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns every computer ref (`[computer:3]`) and take-over ref
|
* Turns every computer ref (`[computer:3]`) and take-over ref
|
||||||
* (`[take-over:3:browser]`), bot ref (`[bot:3]`) and workspace ref
|
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
|
||||||
* (`[workspace:2]`) in a message into a markdown link, so it
|
* (`[workspace:2]`) and file ref (`[file:12]`) in a message into a
|
||||||
* survives markdown rendering as a link the chat can swap for a button.
|
* markdown link, so it survives markdown rendering as a link the chat can
|
||||||
|
* swap for a button or chip.
|
||||||
*/
|
*/
|
||||||
export default function linkComputerRefs({ text }: { text: string }) {
|
export default function linkComputerRefs({ text }: { text: string }) {
|
||||||
return text
|
return text
|
||||||
@@ -44,5 +51,6 @@ export default function linkComputerRefs({ text }: { text: string }) {
|
|||||||
.replace(
|
.replace(
|
||||||
/\[workspace:(\d+)\](?!\()/g,
|
/\[workspace:(\d+)\](?!\()/g,
|
||||||
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
|
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
|
||||||
);
|
)
|
||||||
|
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import resolveBotPath from "./resolve-bot-path";
|
||||||
|
|
||||||
|
function makeRoot() {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "cobble-botfiles-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveBotPath", () => {
|
||||||
|
test("keeps a normal path inside the root", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const resolved = resolveBotPath({ root, path: "reports/q3.md" });
|
||||||
|
|
||||||
|
expect(resolved.relative).toBe("reports/q3.md");
|
||||||
|
expect(resolved.folder).toBe("reports");
|
||||||
|
expect(resolved.name).toBe("q3.md");
|
||||||
|
expect(resolved.absolute.startsWith(root)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes backslashes and extra slashes", () => {
|
||||||
|
const resolved = resolveBotPath({ root: makeRoot(), path: "notes//today.md/" });
|
||||||
|
|
||||||
|
expect(resolved.relative).toBe("notes/today.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses an absolute path", () => {
|
||||||
|
expect(() => resolveBotPath({ root: makeRoot(), path: "/etc/passwd" })).toThrow();
|
||||||
|
expect(() => resolveBotPath({ root: makeRoot(), path: "C:/windows" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses going up out of the root", () => {
|
||||||
|
expect(() => resolveBotPath({ root: makeRoot(), path: "../secret.txt" })).toThrow();
|
||||||
|
expect(() => resolveBotPath({ root: makeRoot(), path: "a/../../secret.txt" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a NUL byte", () => {
|
||||||
|
expect(() => resolveBotPath({ root: makeRoot(), path: "a\0b.txt" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a symlink that leads out of the root", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const outside = makeRoot();
|
||||||
|
writeFileSync(path.join(outside, "secret.txt"), "no");
|
||||||
|
|
||||||
|
mkdirSync(path.join(root, "linked"));
|
||||||
|
symlinkSync(outside, path.join(root, "linked", "escape"));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
resolveBotPath({ root, path: "linked/escape/secret.txt" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a symlink that stays inside", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(path.join(root, "real"));
|
||||||
|
writeFileSync(path.join(root, "real", "note.md"), "hi");
|
||||||
|
symlinkSync(path.join(root, "real"), path.join(root, "alias"));
|
||||||
|
|
||||||
|
expect(resolveBotPath({ root, path: "alias/note.md" }).relative).toBe("alias/note.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a name too long", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveBotPath({ root: makeRoot(), path: `${"a".repeat(200)}.md` }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a path too deep", () => {
|
||||||
|
const deep = Array.from({ length: 12 }, (_, i) => `d${i}`).join("/");
|
||||||
|
|
||||||
|
expect(() => resolveBotPath({ root: makeRoot(), path: `${deep}/file.md` })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a name with characters a disk won't take", () => {
|
||||||
|
expect(() => resolveBotPath({ root: makeRoot(), path: "a<b.md" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { existsSync, realpathSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The directory the file must stay inside (`DATA_DIR/bots/<id>` or
|
||||||
|
* `DATA_DIR/workspaces/<id>`), already absolute.
|
||||||
|
*/
|
||||||
|
root: string;
|
||||||
|
/**
|
||||||
|
* What the bot asked for: a path inside that directory, e.g.
|
||||||
|
* `reports/q3.md`. Never absolute.
|
||||||
|
*/
|
||||||
|
path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedBotPath = {
|
||||||
|
/**
|
||||||
|
* The normalized path inside the root, forward slashes, no leading
|
||||||
|
* slash: what `bot_files.path` holds.
|
||||||
|
*/
|
||||||
|
relative: string;
|
||||||
|
/**
|
||||||
|
* The absolute path on disk, inside `root`.
|
||||||
|
*/
|
||||||
|
absolute: string;
|
||||||
|
/**
|
||||||
|
* The folder part of `relative`, empty at the top.
|
||||||
|
*/
|
||||||
|
folder: string;
|
||||||
|
/**
|
||||||
|
* The last segment.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a bot's path into one safely inside its root: rejects absolute
|
||||||
|
* paths, `..`, NUL bytes and anything too long or too deep, and, once the
|
||||||
|
* path exists, anything a symlink points outside the root. Throws an
|
||||||
|
* `AppError` with a message safe to show the user (the bot gets it back
|
||||||
|
* as the tool's error).
|
||||||
|
*/
|
||||||
|
export default function resolveBotPath({ root, path: requested }: Params): ResolvedBotPath {
|
||||||
|
const raw = (requested || "").trim().replace(/\\/g, "/");
|
||||||
|
|
||||||
|
if (raw.includes("\0")) {
|
||||||
|
throw new AppError(`That path isn't valid.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
|
||||||
|
throw new AppError(
|
||||||
|
`Use a path inside your files folder, not an absolute one (e.g. "reports/q3.md").`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = raw.split("/").map((segment) => segment.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment == "." || segment == "..") {
|
||||||
|
throw new AppError(`That path isn't valid: it must stay inside your files folder.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length > AppData.BotFilePathMaxDepth) {
|
||||||
|
throw new AppError(
|
||||||
|
`That path is too deep: at most ${AppData.BotFilePathMaxDepth} folders.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = segments.at(-1);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new AppError(`Give a file name, e.g. "notes.md".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > AppData.BotFileNameMaxChars) {
|
||||||
|
throw new AppError(
|
||||||
|
`That file name is too long: at most ${AppData.BotFileNameMaxChars} characters.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[<>:"|?*]/.test(name)) {
|
||||||
|
throw new AppError(`A file name can't contain < > : " | ? or *.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = segments.join("/");
|
||||||
|
|
||||||
|
if (relative.length > AppData.BotFilePathMaxChars) {
|
||||||
|
throw new AppError(`That path is too long.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = path.join(root, ...segments);
|
||||||
|
const inside = path.resolve(absolute);
|
||||||
|
|
||||||
|
if (inside != path.resolve(root) && !inside.startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||||
|
throw new AppError(`That path isn't valid: it must stay inside your files folder.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A symlink inside the root must not lead out of it: check the deepest
|
||||||
|
* part that exists.
|
||||||
|
*/
|
||||||
|
guardRealPath({ root, absolute: inside });
|
||||||
|
|
||||||
|
return {
|
||||||
|
relative,
|
||||||
|
absolute: inside,
|
||||||
|
folder: segments.slice(0, -1).join("/"),
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a path whose existing part resolves outside the root through a
|
||||||
|
* symlink (or any other reason).
|
||||||
|
*/
|
||||||
|
function guardRealPath({ root, absolute }: { root: string; absolute: string }) {
|
||||||
|
const real_root = existsSync(root) ? realpathSync(root) : path.resolve(root);
|
||||||
|
let probe = absolute;
|
||||||
|
|
||||||
|
while (!existsSync(probe)) {
|
||||||
|
const parent = path.dirname(probe);
|
||||||
|
|
||||||
|
if (parent == probe) break;
|
||||||
|
|
||||||
|
probe = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const real_probe = realpathSync(probe);
|
||||||
|
|
||||||
|
if (real_probe != real_root && !real_probe.startsWith(`${real_root}${path.sep}`)) {
|
||||||
|
throw new AppError(`That path isn't valid: it must stay inside your files folder.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a path is inside a root, before it exists (used for folders).
|
||||||
|
*/
|
||||||
|
export function isInsideRoot({ root, absolute }: { root: string; absolute: string }) {
|
||||||
|
const resolved_root = path.resolve(root);
|
||||||
|
const resolved = path.resolve(absolute);
|
||||||
|
|
||||||
|
return resolved == resolved_root || resolved.startsWith(`${resolved_root}${path.sep}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import grabStorageScopes from "./storage-access";
|
||||||
|
|
||||||
|
describe("grabStorageScopes", () => {
|
||||||
|
test("a worker in its own chat reaches only its folder", () => {
|
||||||
|
expect(grabStorageScopes({ bot_id: 4, bot_kind: "worker" })).toEqual([
|
||||||
|
{ scope: "bot", owner_id: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a worker in a workspace also reaches the workspace's folder", () => {
|
||||||
|
expect(
|
||||||
|
grabStorageScopes({ bot_id: 4, bot_kind: "worker", workspace_id: 9 }),
|
||||||
|
).toEqual([
|
||||||
|
{ scope: "bot", owner_id: 4 },
|
||||||
|
{ scope: "workspace", owner_id: 9 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the coordinator reaches every workspace, its own folder first", () => {
|
||||||
|
expect(
|
||||||
|
grabStorageScopes({
|
||||||
|
bot_id: 1,
|
||||||
|
bot_kind: "coordinator",
|
||||||
|
all_workspace_ids: [9, 3],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ scope: "bot", owner_id: 1 },
|
||||||
|
{ scope: "workspace", owner_id: 9 },
|
||||||
|
{ scope: "workspace", owner_id: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the coordinator in a workspace still reaches them all", () => {
|
||||||
|
const scopes = grabStorageScopes({
|
||||||
|
bot_id: 1,
|
||||||
|
bot_kind: "coordinator",
|
||||||
|
workspace_id: 9,
|
||||||
|
all_workspace_ids: [9],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scopes).toEqual([
|
||||||
|
{ scope: "bot", owner_id: 1 },
|
||||||
|
{ scope: "workspace", owner_id: 9 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores invalid workspace ids", () => {
|
||||||
|
expect(
|
||||||
|
grabStorageScopes({
|
||||||
|
bot_id: 1,
|
||||||
|
bot_kind: "coordinator",
|
||||||
|
all_workspace_ids: [0, 2],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ scope: "bot", owner_id: 1 },
|
||||||
|
{ scope: "workspace", owner_id: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { BotFileScope } from "@/src/dict/bot-file-dict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One storage directory a run may read and write: a bot's own
|
||||||
|
* (`DATA_DIR/bots/<owner_id>`) or a workspace's shared one
|
||||||
|
* (`DATA_DIR/workspaces/<owner_id>`).
|
||||||
|
*/
|
||||||
|
export type StorageScope = {
|
||||||
|
scope: BotFileScope;
|
||||||
|
owner_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number;
|
||||||
|
/**
|
||||||
|
* `coordinator`: it reaches every bot's and every workspace's files.
|
||||||
|
*/
|
||||||
|
bot_kind?: string | null;
|
||||||
|
/**
|
||||||
|
* The workspace the run is in, if any. A worker then reaches that
|
||||||
|
* workspace's shared files too.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Every workspace, for the coordinator (it isn't a member of one, it
|
||||||
|
* reaches them all). Filled by the caller that already read them.
|
||||||
|
*/
|
||||||
|
all_workspace_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directories a run may touch, itself first. A worker: its own, plus
|
||||||
|
* its workspace's shared one while it works there. The coordinator: every
|
||||||
|
* bot's and every workspace's, its own first.
|
||||||
|
*
|
||||||
|
* Enforced by every file tool, never left to the prompt: a bot that names
|
||||||
|
* a directory it doesn't reach is told so.
|
||||||
|
*/
|
||||||
|
export default function grabStorageScopes({
|
||||||
|
bot_id,
|
||||||
|
bot_kind,
|
||||||
|
workspace_id,
|
||||||
|
all_workspace_ids = [],
|
||||||
|
}: Params): StorageScope[] {
|
||||||
|
const own: StorageScope = { scope: "bot", owner_id: Number(bot_id) };
|
||||||
|
|
||||||
|
if (bot_kind == "coordinator") {
|
||||||
|
return [
|
||||||
|
own,
|
||||||
|
...all_workspace_ids
|
||||||
|
.filter((id) => Number(id) > 0)
|
||||||
|
.map((id): StorageScope => ({ scope: "workspace", owner_id: Number(id) })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspace_id ? [own, { scope: "workspace", owner_id: Number(workspace_id) }] : [own];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a scope is one of the run's.
|
||||||
|
*/
|
||||||
|
export function canReachScope({
|
||||||
|
scopes,
|
||||||
|
scope,
|
||||||
|
owner_id,
|
||||||
|
}: {
|
||||||
|
scopes: StorageScope[];
|
||||||
|
scope: BotFileScope;
|
||||||
|
owner_id: number | string;
|
||||||
|
}) {
|
||||||
|
return scopes.some((s) => s.scope == scope && s.owner_id == Number(owner_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scope's label for tools and messages: "your files" for a bot's own,
|
||||||
|
* the workspace's name for a shared one.
|
||||||
|
*/
|
||||||
|
export function storageScopeKey({ scope, owner_id }: StorageScope) {
|
||||||
|
return `${scope}:${owner_id}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user