diff --git a/docs/api/README.md b/docs/api/README.md index 2a91911..293d227 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -246,11 +246,20 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | Signal | Emitted by | |--------|------------| -| `article-` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` | -| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}` | +| `article-` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight`, `POST /stock/adjust` | +| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `POST /stock/adjust` | +| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}` | | `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` | +| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` | +| `ledger` | `POST /stock/adjust` | +| `modelconversation` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | +| `modelconversation-` | `POST /modelconversation/{id}/message` | +| `printer` | `POST /printer`, `PUT /printer/order`, `DELETE /printer/{id}`, `PATCH /printer/{id}`, `PUT /printer/{id}` | +| `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` | +| `stock` | `POST /stock/adjust` | | `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` | +| `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` | ## DESIGN diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 6504c9a..7cb1bc4 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -214,8 +214,8 @@ _none_ - Params: `body: { companyId: int }` - Returns: `200 { user: T34 }` #### List brands — `GET /brand` -- Params: `query: offset?: int, limit?: int` -- Returns: `200 { brands: { id: int, name: str, pics: T36[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` +- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool` +- Returns: `200 { brands: { id: int, name: str, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` #### Create brand — `POST /brand` - Params: `body: { name: str, gsprpinfo?: str }` - Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }` @@ -327,6 +327,15 @@ _none_ #### Parse document (SSE) — `POST /delivery/parse` - Params: `form: { file: str }` - Returns: `200 SSE` + +- Events: + - `status`: `{ "message": "…" }` + - `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }` + - `tool_call`: `{ "name": "output_manifest" }` + - `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }` + - `progress`: `ManifestResult` + - `result`: `ManifestResult` + - `error`: `{ "message": "…" }` #### Delete delivery — `DELETE /delivery/{id}` - Params: `id: int` - Returns: `204 void` @@ -384,6 +393,22 @@ _none_ #### Subscribe to update signals (SSE) — `GET /events` _none_ - Returns: `200 SSE` + +- Events: + - `category`: `{}` + - `articles`: `{}` + - `article-`: `{}` + - `stock`: `{}` + - `ledger`: `{}` + - `attributename`: `{}` + - `propertyname`: `{}` + - `labeltemplate`: `{}` + - `printer`: `{}` + - `warehouse`: `{}` + - `vfs`: `{}` + - `modelconversation`: `{}` + - `modelconversation-`: `{}` + - `brand`: `{ sender, brandId }` #### Database health check — `GET /health` _none_ - Returns: `200 { ok: bool }` @@ -573,6 +598,12 @@ _none_ #### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images` - Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }` - Returns: `200 SSE` + +- Events: + - `images`: `{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }` + - `thumb`: `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }` + - `done`: `{ "total": 100 }` + - `error`: `{ "error": "…" }` #### Delete shipping — `DELETE /shipping/{id}` - Params: `id: int` - Returns: `204 void` diff --git a/docs/api/routes.md b/docs/api/routes.md index 44fa3dc..b31365f 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -197,8 +197,8 @@ _none_ _none_ - Returns: `200 { message: str }` #### List brands — `GET /brand` -- Params: `query: offset?: int, limit?: int` -- Returns: `200 { brands: { id: int, name: str, pics: T32[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` +- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool` +- Returns: `200 { brands: { id: int, name: str, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` #### Create brand — `POST /brand` - Params: `body: { name: str, gsprpinfo?: str }` - Returns: `201 { brand: { id: int, name: str, pics: T32[], gsprpinfo?: str|null } }` diff --git a/docs/api/routes/attributename.post.md b/docs/api/routes/attributename.post.md index e1a26c9..f7ef090 100644 --- a/docs/api/routes/attributename.post.md +++ b/docs/api/routes/attributename.post.md @@ -22,6 +22,10 @@ Creates an attribute name for the selected company. } ``` +## Signals + +On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)). + ## Response `201` Created attribute name diff --git a/docs/api/routes/attributename/_id.delete.md b/docs/api/routes/attributename/_id.delete.md index ad289e6..7353654 100644 --- a/docs/api/routes/attributename/_id.delete.md +++ b/docs/api/routes/attributename/_id.delete.md @@ -12,6 +12,10 @@ Deletes an attribute name and cascades to attribute values and article assignmen |-------|----------|------|-------------| | `id` | yes | integer | | +## Signals + +On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)). + ## Response `200` Deleted successfully diff --git a/docs/api/routes/attributename/_id.patch.md b/docs/api/routes/attributename/_id.patch.md index 6ea57a3..cf111ec 100644 --- a/docs/api/routes/attributename/_id.patch.md +++ b/docs/api/routes/attributename/_id.patch.md @@ -28,6 +28,10 @@ Updates name, sort, and/or exclusive flag for an attribute name. } ``` +## Signals + +On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)). + ## Response `200` Updated attribute name diff --git a/docs/api/routes/attributename/order.md b/docs/api/routes/attributename/order.md index bbe0a01..c906354 100644 --- a/docs/api/routes/attributename/order.md +++ b/docs/api/routes/attributename/order.md @@ -20,6 +20,10 @@ Updates the display sort order of attribute names for the selected company. } ``` +## Signals + +On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)). + ## Response `200` Reordered successfully diff --git a/docs/api/routes/brand/list.md b/docs/api/routes/brand/list.md index 8718f77..4ca4227 100644 --- a/docs/api/routes/brand/list.md +++ b/docs/api/routes/brand/list.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). List brands -Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. +Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. Small-thumbnail fields are omitted by default. Set `includeSmallThumbs=true` to include each picture's `smallThumbW`, `smallThumbH`, and `smallThumbBinary` (base64-encoded AVIF bytes, resized with a 40px maximum edge). ## Query parameters @@ -12,6 +12,7 @@ Lists brands for the authenticated user's selected company, sorted by name. Page |-------|----------|------|-------------| | `offset` | no | integer | Number of brands to skip | | `limit` | no | integer | Number of brands to return | +| `includeSmallThumbs` | no | boolean | Include each picture's small thumbnail dimensions and base64 AVIF bytes. | ## Response `200` @@ -29,7 +30,10 @@ Brand list "fullHash": "…", "thumbW": 1, "thumbH": 1, - "thumbHash": "…" + "thumbHash": "…", + "smallThumbW": 1, + "smallThumbH": 1, + "smallThumbBinary": "…" } ] } @@ -44,7 +48,7 @@ Brand list | Status | Description | |--------|-------------| -| 400 | Invalid offset, invalid limit, or no company selected — `{"error":"…"}` | +| 400 | Invalid offset, invalid limit, invalid includeSmallThumbs, or no company selected — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` | | 404 | Company not found — `{"error":"…"}` | diff --git a/docs/api/routes/events.md b/docs/api/routes/events.md index f32f1c6..74583de 100644 --- a/docs/api/routes/events.md +++ b/docs/api/routes/events.md @@ -24,21 +24,36 @@ to the company at connect time). ## Signals -| Signal | Emitted after successful | -|--------|--------------------------| -| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` | -| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` | -| `article-` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` | -| `brand` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` | +Every SSE event uses the event name as the signal id; payloads are +JSON and most events send an empty object `{}`. Only `brand` emits a +richer payload. -The `brand` signal carries a JSON payload `{ sender, brandId }`: -`sender` echoes the caller's `x-client-id` header (empty string if -absent) so the sender can ignore its own event; `brandId` is the -affected brand id (`null` only if unknown). Other signals send `{}`. +| Signal | Payload | Emitted after successful | +|--------|---------|--------------------------| +| `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` | +| `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations | +| `article-` | `{}` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, supplier/article-property mutations affecting the article | +| `stock` | `{}` | `POST /stock/adjust` | +| `ledger` | `{}` | `POST /stock/adjust` | +| `attributename` | `{}` | `POST /attributename`, `PUT /attributename/order`, `PATCH /attributename/{id}`, `DELETE /attributename/{id}` | +| `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` | +| `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` | +| `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` | +| `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` | +| `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` | +| `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | +| `modelconversation-` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name | +| `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` | + +The `brand` payload is `{ sender, brandId }`: `sender` echoes the +caller's `x-client-id` header (empty string if absent) so the sender +can ignore its own event; `brandId` is the affected brand id (`null` +only if unknown). All other signals send `{}`. Signals are only sent when the mutation actually succeeds (2xx). Failed or no-op validation errors do not emit. The `article-` -event name uses the concrete SKU (e.g. `article-42000045`). +and `modelconversation-` event names use the concrete item id at +runtime (for example `article-42000045` and `modelconversation-123`). ## Response `200` diff --git a/docs/api/routes/labeltemplate/create.md b/docs/api/routes/labeltemplate/create.md index 69ab417..8a19a94 100644 --- a/docs/api/routes/labeltemplate/create.md +++ b/docs/api/routes/labeltemplate/create.md @@ -46,6 +46,10 @@ and stored with the template. } ``` +## Signals + +On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)). + ## Response `201` Created label template diff --git a/docs/api/routes/labeltemplate/delete.md b/docs/api/routes/labeltemplate/delete.md index cc334e1..f9fdca3 100644 --- a/docs/api/routes/labeltemplate/delete.md +++ b/docs/api/routes/labeltemplate/delete.md @@ -12,6 +12,10 @@ Deletes a label template for the authenticated user's selected company. |-------|----------|------|-------------| | `id` | yes | integer | Label template ID | +## Signals + +On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)). + ## Response `204` Label template deleted diff --git a/docs/api/routes/labeltemplate/update.md b/docs/api/routes/labeltemplate/update.md index af2e343..7f1710e 100644 --- a/docs/api/routes/labeltemplate/update.md +++ b/docs/api/routes/labeltemplate/update.md @@ -51,6 +51,10 @@ When `content` (or `width`/`height`) is provided, the thumbnail is automatically } ``` +## Signals + +On success, emits SSE signal: `labeltemplate` (see [`GET /events`](../events.md)). + ## Response `200` Updated label template diff --git a/docs/api/routes/modelconversation.post.md b/docs/api/routes/modelconversation.post.md index 4a13e69..d75d180 100644 --- a/docs/api/routes/modelconversation.post.md +++ b/docs/api/routes/modelconversation.post.md @@ -20,6 +20,10 @@ Creates a new conversation in state `idle`. } ``` +## Signals + +On success, emits SSE signal: `modelconversation` (see [`GET /events`](../events.md)). + ## Response `201` Created conversation diff --git a/docs/api/routes/modelconversation/_id.delete.md b/docs/api/routes/modelconversation/_id.delete.md index 9965d48..a6f98dc 100644 --- a/docs/api/routes/modelconversation/_id.delete.md +++ b/docs/api/routes/modelconversation/_id.delete.md @@ -12,6 +12,10 @@ Deletes one of the authenticated user's model conversations and all its messages |-------|----------|------|-------------| | `id` | yes | integer | Conversation ID | +## Signals + +On success, emits SSE signal: `modelconversation` (see [`GET /events`](../events.md)). + ## Response `204` Conversation deleted diff --git a/docs/api/routes/modelconversation/_id/message.post.md b/docs/api/routes/modelconversation/_id/message.post.md index 545400e..5115234 100644 --- a/docs/api/routes/modelconversation/_id/message.post.md +++ b/docs/api/routes/modelconversation/_id/message.post.md @@ -34,6 +34,10 @@ Refused with 409 unless the conversation state is `idle`. } ``` +## Signals + +On success, emits SSE signal: `modelconversation-` (see [`GET /events`](../events.md)). + ## Response `201` Created message diff --git a/docs/api/routes/printer/_id.put.md b/docs/api/routes/printer/_id.put.md index 38ebb50..0766982 100644 --- a/docs/api/routes/printer/_id.put.md +++ b/docs/api/routes/printer/_id.put.md @@ -34,6 +34,10 @@ Updates specified fields of an existing printer for the selected company. } ``` +## Signals + +On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)). + ## Response `200` Updated printer diff --git a/docs/api/routes/printer/create.md b/docs/api/routes/printer/create.md index b2d3087..7d981a1 100644 --- a/docs/api/routes/printer/create.md +++ b/docs/api/routes/printer/create.md @@ -30,6 +30,10 @@ Creates a new printer for the selected company. } ``` +## Signals + +On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)). + ## Response `201` Created printer diff --git a/docs/api/routes/printer/delete.md b/docs/api/routes/printer/delete.md index 5852742..1232847 100644 --- a/docs/api/routes/printer/delete.md +++ b/docs/api/routes/printer/delete.md @@ -12,6 +12,10 @@ Deletes a printer for the authenticated user's selected company. |-------|----------|------|-------------| | `id` | yes | integer | Printer ID | +## Signals + +On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)). + ## Response `204` Printer deleted diff --git a/docs/api/routes/printer/order.md b/docs/api/routes/printer/order.md index 5466aac..3a1d9ea 100644 --- a/docs/api/routes/printer/order.md +++ b/docs/api/routes/printer/order.md @@ -22,6 +22,10 @@ Reorders printers for the selected company. } ``` +## Signals + +On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)). + ## Response `200` Applied printer order diff --git a/docs/api/routes/printer/update.md b/docs/api/routes/printer/update.md index b0704f4..8683e00 100644 --- a/docs/api/routes/printer/update.md +++ b/docs/api/routes/printer/update.md @@ -34,6 +34,10 @@ Updates specified fields of an existing printer for the selected company. } ``` +## Signals + +On success, emits SSE signal: `printer` (see [`GET /events`](../events.md)). + ## Response `200` Updated printer diff --git a/docs/api/routes/propertyname.post.md b/docs/api/routes/propertyname.post.md index accdf31..267c5e2 100644 --- a/docs/api/routes/propertyname.post.md +++ b/docs/api/routes/propertyname.post.md @@ -22,6 +22,10 @@ Creates a new property name for the selected company. } ``` +## Signals + +On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)). + ## Response `201` Created property name diff --git a/docs/api/routes/propertyname/_id.delete.md b/docs/api/routes/propertyname/_id.delete.md index 8665ecf..1e45c9f 100644 --- a/docs/api/routes/propertyname/_id.delete.md +++ b/docs/api/routes/propertyname/_id.delete.md @@ -12,6 +12,10 @@ Deletes a property name and cascades to article properties. |-------|----------|------|-------------| | `id` | yes | integer | | +## Signals + +On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)). + ## Response `200` Deleted successfully diff --git a/docs/api/routes/propertyname/_id.patch.md b/docs/api/routes/propertyname/_id.patch.md index 9876390..74ff654 100644 --- a/docs/api/routes/propertyname/_id.patch.md +++ b/docs/api/routes/propertyname/_id.patch.md @@ -28,6 +28,10 @@ Updates name and/or sort for a property name. } ``` +## Signals + +On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)). + ## Response `200` Updated property name diff --git a/docs/api/routes/propertyname/order.md b/docs/api/routes/propertyname/order.md index a34d3ad..d8b0caa 100644 --- a/docs/api/routes/propertyname/order.md +++ b/docs/api/routes/propertyname/order.md @@ -20,6 +20,10 @@ Updates the display sort order of property names for the selected company. } ``` +## Signals + +On success, emits SSE signal: `propertyname` (see [`GET /events`](../events.md)). + ## Response `200` Reordered successfully diff --git a/docs/api/routes/stock/adjust.md b/docs/api/routes/stock/adjust.md index b3c53e9..1ff15f6 100644 --- a/docs/api/routes/stock/adjust.md +++ b/docs/api/routes/stock/adjust.md @@ -41,6 +41,10 @@ of type "adjustment", allocating a number from the "adjustment" number scheme. } ``` +## Signals + +On success, emits SSE signals: `stock`, `ledger`, `articles`, `article-` (see [`GET /events`](../events.md)). + ## Response `201` Stock adjusted successfully diff --git a/docs/api/routes/warehouse/_id.put.md b/docs/api/routes/warehouse/_id.put.md index e2c5a56..47e8671 100644 --- a/docs/api/routes/warehouse/_id.put.md +++ b/docs/api/routes/warehouse/_id.put.md @@ -26,6 +26,10 @@ Updates specified fields of an existing warehouse for the selected company. } ``` +## Signals + +On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)). + ## Response `200` Updated warehouse diff --git a/docs/api/routes/warehouse/create.md b/docs/api/routes/warehouse/create.md index b703ac3..60fcb61 100644 --- a/docs/api/routes/warehouse/create.md +++ b/docs/api/routes/warehouse/create.md @@ -22,6 +22,10 @@ Sort order will be automatically appended if omitted. } ``` +## Signals + +On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)). + ## Response `201` Created warehouse diff --git a/docs/api/routes/warehouse/delete.md b/docs/api/routes/warehouse/delete.md index 725af0b..c773ae2 100644 --- a/docs/api/routes/warehouse/delete.md +++ b/docs/api/routes/warehouse/delete.md @@ -12,6 +12,10 @@ Deletes a warehouse if not in use in the ledger. |-------|----------|------|-------------| | `id` | yes | integer | Warehouse ID | +## Signals + +On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)). + ## Response `204` Warehouse deleted diff --git a/docs/api/routes/warehouse/order.md b/docs/api/routes/warehouse/order.md index 207b4a9..12dca10 100644 --- a/docs/api/routes/warehouse/order.md +++ b/docs/api/routes/warehouse/order.md @@ -22,6 +22,10 @@ Reorders warehouses for the selected company. } ``` +## Signals + +On success, emits SSE signal: `warehouse` (see [`GET /events`](../events.md)). + ## Response `200` Applied warehouse order diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index de5bb56..6ab1199 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -361,7 +361,7 @@ function renderOperation(apiPath, method, operation) { } const sseEvents = operation['x-sse-events']; - if (Array.isArray(sseEvents) && sseEvents.length) { + if (apiPath !== '/events' && Array.isArray(sseEvents) && sseEvents.length) { lines.push('## SSE Events'); lines.push(''); lines.push('The response is a `text/event-stream`. Events are emitted in order until the stream closes.'); @@ -773,12 +773,23 @@ function renderRoutesOverview(operations, title = 'Dash API Routes Overview') { for (const op of operations) { const params = summarizeOperationParams(op.operation, aliases); const returns = summarizeOperationReturns(op.operation, aliases); + const hasSseResponse = Object.values(op.operation.responses || {}).some((response) => + Object.hasOwn(response.content || {}, 'text/event-stream'), + ); const method = op.method.toUpperCase(); const summary = (op.summary || '').replace(/[\r\n]+/g, ' ').trim(); const heading = summary ? `${summary} — \`${method} ${op.path}\`` : `\`${method} ${op.path}\``; lines.push(`#### ${heading}`); lines.push(params === '—' ? '_none_' : `- Params: \`${params}\``); lines.push(returns === '—' ? '_no response body_' : `- Returns: \`${returns}\``); + const sseEvents = op.operation['x-sse-events']; + if (hasSseResponse && Array.isArray(sseEvents) && sseEvents.length) { + lines.push(''); + lines.push('- Events:'); + for (const event of sseEvents) { + lines.push(` - \`${event.name}\`: \`${event.payload || '{}'}\``); + } + } } lines.push(''); return lines.join('\n'); diff --git a/src/api/openapi.js b/src/api/openapi.js index 8f86488..8761ce2 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -369,6 +369,47 @@ * description: Brand pictures sorted by sort * items: * $ref: '#/components/schemas/BrandPic' + * BrandListPic: + * type: object + * required: [fullHash, thumbW, thumbH, thumbHash] + * properties: + * fullHash: + * type: string + * thumbW: + * type: integer + * thumbH: + * type: integer + * thumbHash: + * type: string + * smallThumbW: + * type: integer + * nullable: true + * description: Width in pixels of the small AVIF thumbnail + * smallThumbH: + * type: integer + * nullable: true + * description: Height in pixels of the small AVIF thumbnail + * smallThumbBinary: + * type: string + * format: byte + * nullable: true + * description: Base64-encoded small AVIF thumbnail bytes + * BrandList: + * type: object + * required: [id, name, pics] + * properties: + * id: + * type: integer + * name: + * type: string + * gsprpinfo: + * type: string + * nullable: true + * pics: + * type: array + * description: Brand pictures sorted by sort, including the 40px-edge small thumbnail + * items: + * $ref: '#/components/schemas/BrandListPic' * ArticleBrand: * type: object * required: [name] @@ -1619,4 +1660,3 @@ */ export {}; - diff --git a/src/api/routes/attributename.js b/src/api/routes/attributename.js index 6e2edd0..0c7f0c8 100644 --- a/src/api/routes/attributename.js +++ b/src/api/routes/attributename.js @@ -55,6 +55,7 @@ export function register(app, basePath = '') { * description: Creates an attribute name for the selected company. * security: * - cookieAuth: [] + * x-signals: [attributename] * requestBody: * required: true * content: @@ -118,6 +119,7 @@ export function register(app, basePath = '') { * description: Updates the display sort order of attribute names for the selected company. * security: * - cookieAuth: [] + * x-signals: [attributename] * requestBody: * required: true * content: @@ -162,6 +164,7 @@ export function register(app, basePath = '') { * description: Updates name, sort, and/or exclusive flag for an attribute name. * security: * - cookieAuth: [] + * x-signals: [attributename] * parameters: * - in: path * name: id @@ -197,6 +200,7 @@ export function register(app, basePath = '') { * description: Deletes an attribute name and cascades to attribute values and article assignments. * security: * - cookieAuth: [] + * x-signals: [attributename] * parameters: * - in: path * name: id diff --git a/src/api/routes/brand.js b/src/api/routes/brand.js index 0b7f4a4..2745a15 100644 --- a/src/api/routes/brand.js +++ b/src/api/routes/brand.js @@ -47,7 +47,7 @@ export function register(app, basePath = '') { * get: * tags: [Brand] * summary: List brands - * description: Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. + * description: Lists brands for the authenticated user's selected company, sorted by name. Paged; `total` is the unpaged count. Small-thumbnail fields are omitted by default. Set `includeSmallThumbs=true` to include each picture's `smallThumbW`, `smallThumbH`, and `smallThumbBinary` (base64-encoded AVIF bytes, resized with a 40px maximum edge). * security: * - cookieAuth: [] * parameters: @@ -66,6 +66,12 @@ export function register(app, basePath = '') { * minimum: 1 * maximum: 100 * description: Number of brands to return + * - in: query + * name: includeSmallThumbs + * schema: + * type: boolean + * default: false + * description: Include each picture's small thumbnail dimensions and base64 AVIF bytes. * x-markdown: brand/list.md * responses: * 200: @@ -79,7 +85,7 @@ export function register(app, basePath = '') { * brands: * type: array * items: - * $ref: '#/components/schemas/Brand' + * $ref: '#/components/schemas/BrandList' * total: * type: integer * description: Total number of unpaged brands @@ -90,7 +96,7 @@ export function register(app, basePath = '') { * type: integer * description: Number of brands returned * 400: - * description: Invalid offset, invalid limit, or no company selected + * description: Invalid offset, invalid limit, invalid includeSmallThumbs, or no company selected * content: * application/json: * schema: @@ -122,6 +128,15 @@ export function register(app, basePath = '') { const rawOffset = req.query.offset; const rawLimit = req.query.limit; + const rawIncludeSmallThumbs = req.query.includeSmallThumbs; + + if ( + rawIncludeSmallThumbs !== undefined + && rawIncludeSmallThumbs !== 'true' + && rawIncludeSmallThumbs !== 'false' + ) { + return res.status(400).json({ error: 'includeSmallThumbs must be true or false' }); + } let offset = 0; let limit = 10; @@ -141,7 +156,11 @@ export function register(app, basePath = '') { } try { - const result = await db.list(req.user.id, { offset, limit }); + const result = await db.list(req.user.id, { + offset, + limit, + includeSmallThumbs: rawIncludeSmallThumbs === 'true', + }); res.status(200).json(result); } catch (error) { const status = brandErrorStatus(error); diff --git a/src/api/routes/events.js b/src/api/routes/events.js index d4aa8de..6504afc 100644 --- a/src/api/routes/events.js +++ b/src/api/routes/events.js @@ -33,24 +33,68 @@ export function register(app, basePath = '') { * * ## Signals * - * | Signal | Emitted after successful | - * |--------|--------------------------| - * | `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` | - * | `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` | - * | `article-` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` | - * | `brand` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` | + * Every SSE event uses the event name as the signal id; payloads are + * JSON and most events send an empty object `{}`. Only `brand` emits a + * richer payload. * - * The `brand` signal carries a JSON payload `{ sender, brandId }`: - * `sender` echoes the caller's `x-client-id` header (empty string if - * absent) so the sender can ignore its own event; `brandId` is the - * affected brand id (`null` only if unknown). Other signals send `{}`. + * | Signal | Payload | Emitted after successful | + * |--------|---------|--------------------------| + * | `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` | + * | `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations | + * | `article-` | `{}` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, supplier/article-property mutations affecting the article | + * | `stock` | `{}` | `POST /stock/adjust` | + * | `ledger` | `{}` | `POST /stock/adjust` | + * | `attributename` | `{}` | `POST /attributename`, `PUT /attributename/order`, `PATCH /attributename/{id}`, `DELETE /attributename/{id}` | + * | `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` | + * | `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` | + * | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` | + * | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` | + * | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` | + * | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | + * | `modelconversation-` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name | + * | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` | + * + * The `brand` payload is `{ sender, brandId }`: `sender` echoes the + * caller's `x-client-id` header (empty string if absent) so the sender + * can ignore its own event; `brandId` is the affected brand id (`null` + * only if unknown). All other signals send `{}`. * * Signals are only sent when the mutation actually succeeds (2xx). * Failed or no-op validation errors do not emit. The `article-` - * event name uses the concrete SKU (e.g. `article-42000045`). + * and `modelconversation-` event names use the concrete item id at + * runtime (for example `article-42000045` and `modelconversation-123`). * security: * - cookieAuth: [] * x-markdown: events.md + * x-sse-events: + * - name: category + * payload: "{}" + * - name: articles + * payload: "{}" + * - name: article- + * payload: "{}" + * - name: stock + * payload: "{}" + * - name: ledger + * payload: "{}" + * - name: attributename + * payload: "{}" + * - name: propertyname + * payload: "{}" + * - name: labeltemplate + * payload: "{}" + * - name: printer + * payload: "{}" + * - name: warehouse + * payload: "{}" + * - name: vfs + * payload: "{}" + * - name: modelconversation + * payload: "{}" + * - name: modelconversation- + * payload: "{}" + * - name: brand + * payload: "{ sender, brandId }" * responses: * 200: * description: | diff --git a/src/api/routes/labeltemplate.js b/src/api/routes/labeltemplate.js index 03df190..4a98fa4 100644 --- a/src/api/routes/labeltemplate.js +++ b/src/api/routes/labeltemplate.js @@ -176,6 +176,7 @@ export function register(app, basePath = '') { * and stored with the template. * security: * - cookieAuth: [] + * x-signals: [labeltemplate] * x-markdown: labeltemplate/create.md * requestBody: * required: true @@ -293,6 +294,7 @@ export function register(app, basePath = '') { * When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered. * security: * - cookieAuth: [] + * x-signals: [labeltemplate] * x-markdown: labeltemplate/update.md * parameters: * - in: path @@ -343,6 +345,7 @@ export function register(app, basePath = '') { * When `content` (or `width`/`height`) is provided, the thumbnail is automatically re-rendered. * security: * - cookieAuth: [] + * x-signals: [labeltemplate] * x-markdown: labeltemplate/update.md * parameters: * - in: path @@ -398,6 +401,7 @@ export function register(app, basePath = '') { * description: Deletes a label template for the authenticated user's selected company. * security: * - cookieAuth: [] + * x-signals: [labeltemplate] * x-markdown: labeltemplate/delete.md * parameters: * - in: path diff --git a/src/api/routes/modelconversation.js b/src/api/routes/modelconversation.js index c5e57d8..c77002d 100644 --- a/src/api/routes/modelconversation.js +++ b/src/api/routes/modelconversation.js @@ -104,6 +104,7 @@ export function register(app, basePath = '') { * description: Creates a new conversation in state `idle`. * security: * - cookieAuth: [] + * x-signals: [modelconversation] * requestBody: * required: true * content: @@ -248,6 +249,7 @@ export function register(app, basePath = '') { * description: Deletes one of the authenticated user's model conversations and all its messages. * security: * - cookieAuth: [] + * x-signals: [modelconversation] * parameters: * - in: path * name: id @@ -493,6 +495,7 @@ export function register(app, basePath = '') { * Refused with 409 unless the conversation state is `idle`. * security: * - cookieAuth: [] + * x-signals: [modelconversation-] * parameters: * - in: path * name: id diff --git a/src/api/routes/printer.js b/src/api/routes/printer.js index ea5e652..129da4d 100644 --- a/src/api/routes/printer.js +++ b/src/api/routes/printer.js @@ -103,6 +103,7 @@ export function register(app, basePath = '') { * `printerIds` must include all existing printer IDs exactly once. * security: * - cookieAuth: [] + * x-signals: [printer] * x-markdown: printer/order.md * requestBody: * required: true @@ -253,6 +254,7 @@ export function register(app, basePath = '') { * `type` must be `"esc/pos"` (lines), `"zpl"` (pages), or `"ipp"`. * security: * - cookieAuth: [] + * x-signals: [printer] * x-markdown: printer/create.md * requestBody: * required: true @@ -355,6 +357,7 @@ export function register(app, basePath = '') { * description: Updates specified fields of an existing printer for the selected company. * security: * - cookieAuth: [] + * x-signals: [printer] * parameters: * - in: path * name: id @@ -409,6 +412,7 @@ export function register(app, basePath = '') { * description: Updates specified fields of an existing printer for the selected company. * security: * - cookieAuth: [] + * x-signals: [printer] * parameters: * - in: path * name: id @@ -469,6 +473,7 @@ export function register(app, basePath = '') { * description: Deletes a printer for the authenticated user's selected company. * security: * - cookieAuth: [] + * x-signals: [printer] * parameters: * - in: path * name: id diff --git a/src/api/routes/propertyname.js b/src/api/routes/propertyname.js index 57bac86..3c33c16 100644 --- a/src/api/routes/propertyname.js +++ b/src/api/routes/propertyname.js @@ -61,6 +61,7 @@ export function register(app, basePath = '') { * description: Creates a new property name for the selected company. * security: * - cookieAuth: [] + * x-signals: [propertyname] * requestBody: * required: true * content: @@ -130,6 +131,7 @@ export function register(app, basePath = '') { * description: Updates the display sort order of property names for the selected company. * security: * - cookieAuth: [] + * x-signals: [propertyname] * requestBody: * required: true * content: @@ -176,6 +178,7 @@ export function register(app, basePath = '') { * description: Updates name and/or sort for a property name. * security: * - cookieAuth: [] + * x-signals: [propertyname] * parameters: * - in: path * name: id @@ -211,6 +214,7 @@ export function register(app, basePath = '') { * description: Deletes a property name and cascades to article properties. * security: * - cookieAuth: [] + * x-signals: [propertyname] * parameters: * - in: path * name: id diff --git a/src/api/routes/stock.js b/src/api/routes/stock.js index 406dbd0..37ea118 100644 --- a/src/api/routes/stock.js +++ b/src/api/routes/stock.js @@ -71,6 +71,7 @@ export function register(app, basePath = '') { * **Note:** `sku` must be a string (e.g. `"25"`), not a number. For single-item adjustments, provide `sku` and `amount` at top-level. For batch adjustments, provide `lines: [{ sku, amount, ... }]`. * security: * - cookieAuth: [] + * x-signals: [stock, ledger, articles, article-] * x-markdown: stock/adjust.md * requestBody: * required: true diff --git a/src/api/routes/warehouse.js b/src/api/routes/warehouse.js index 971e6cd..81f7ead 100644 --- a/src/api/routes/warehouse.js +++ b/src/api/routes/warehouse.js @@ -104,6 +104,7 @@ export function register(app, basePath = '') { * `warehouseIds` must include all existing warehouse IDs exactly once. * security: * - cookieAuth: [] + * x-signals: [warehouse] * x-markdown: warehouse/order.md * requestBody: * required: true @@ -254,6 +255,7 @@ export function register(app, basePath = '') { * Sort order will be automatically appended if omitted. * security: * - cookieAuth: [] + * x-signals: [warehouse] * x-markdown: warehouse/create.md * requestBody: * required: true @@ -408,6 +410,7 @@ export function register(app, basePath = '') { * description: Updates specified fields of an existing warehouse for the selected company. * security: * - cookieAuth: [] + * x-signals: [warehouse] * parameters: * - in: path * name: id @@ -474,6 +477,7 @@ export function register(app, basePath = '') { * description: Deletes a warehouse if not in use in the ledger. * security: * - cookieAuth: [] + * x-signals: [warehouse] * parameters: * - in: path * name: id diff --git a/src/tools/javascript-worker.js b/src/tools/javascript-worker.js index a61dd83..5286d9e 100644 --- a/src/tools/javascript-worker.js +++ b/src/tools/javascript-worker.js @@ -10,7 +10,19 @@ import ExcelJS from 'exceljs'; import PDFDocument from 'pdfkit'; import { createVfs } from './vfs.js'; -const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData; +const { + code, + root, + vmTimeout, + bufferLimit, + network, + dashbackUrl, + dashbackToken, + vfsDir, + googleAuthId, + appletInput, + appletFile, +} = workerData; function resolveDashbackUrl(inputUrl, baseApiUrl) { const raw = String(inputUrl || '').trim(); @@ -582,7 +594,13 @@ try { let value; let failed = null; try { - value = vm.run(code, 'javascript_execute.js'); + value = vm.run(code, appletFile || 'javascript_execute.js'); + if (appletFile) { + if (typeof value !== 'function') { + throw new TypeError('Applet must assign an exported function to module.exports.'); + } + value = value(appletInput); + } if (value && typeof value.then === 'function') { const settleTimeout = Math.max(1000, vmTimeout - 500); value = await Promise.race([ diff --git a/src/tools/javascript.js b/src/tools/javascript.js index 9194286..94988eb 100644 --- a/src/tools/javascript.js +++ b/src/tools/javascript.js @@ -1,10 +1,11 @@ import { Worker } from 'worker_threads'; import { fileURLToPath } from 'url'; +import fs from 'fs'; import path from 'path'; import jwt from 'jsonwebtoken'; import { defineTool, ToolError } from 'libllmorbiter'; import env from '../config/env.js'; -import { prepareVfs, syncVfsToDb } from './vfs.js'; +import { createVfs, prepareVfs, syncVfsToDb } from './vfs.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(__dirname, '../..'); @@ -20,7 +21,7 @@ const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024; // so warning per message floods the log. We warn once per distinct key instead. const WATCH_NOISE_WARNED = new Set(); -function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) { +function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appletFile }) { return new Promise((resolve) => { let settled = false; const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); @@ -35,6 +36,8 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) { dashbackToken: token, vfsDir: vfsRoot, googleAuthId, + appletInput, + appletFile, }, }); @@ -104,6 +107,67 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) { }); } +function readApplet(vfsRoot, appletvfsfile) { + if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) { + throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.'); + } + const appletPath = appletvfsfile.trim(); + if (path.isAbsolute(appletPath) || /^[a-zA-Z]:/.test(appletPath) || appletPath.startsWith('vfs:')) { + throw new ToolError('"appletvfsfile" must be a VFS-relative file path.'); + } + if (!appletPath.toLowerCase().endsWith('.applet.js')) { + throw new ToolError('"appletvfsfile" must refer to a .applet.js file.'); + } + + const vfs = createVfs(vfsRoot); + let source; + try { + const diskPath = vfs.resolveReadPath(appletPath); + const realRoot = fs.realpathSync(vfsRoot); + const realPath = fs.realpathSync(diskPath); + const relativePath = path.relative(realRoot, realPath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new ToolError(`Applet "${appletPath}" resolves outside the VFS.`); + } + if (!fs.statSync(realPath).isFile()) { + throw new ToolError(`Applet "${appletPath}" is not a file.`); + } + source = fs.readFileSync(realPath, 'utf8'); + } catch (err) { + if (err instanceof ToolError) throw err; + throw new ToolError(`Unable to read applet "${appletPath}" from the VFS: ${err.message}`); + } + + const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 3); + const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/); + const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/); + if (!inputHeader || !outputHeader) { + throw new ToolError( + 'Applet metadata must be on the first two lines: ' + + '`// @applet-input-schema {JSON Schema}` followed by ' + + '`// @applet-output-schema {JSON Schema}`.' + ); + } + + let inputSchema; + let outputSchema; + try { + inputSchema = JSON.parse(inputHeader[1]); + outputSchema = JSON.parse(outputHeader[1]); + } catch (err) { + throw new ToolError(`Applet metadata must contain valid JSON schemas: ${err.message}`); + } + if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema) || + inputSchema.type !== 'object') { + throw new ToolError('Applet input schema must be a JSON Schema with type "object".'); + } + if (!outputSchema || typeof outputSchema !== 'object' || Array.isArray(outputSchema)) { + throw new ToolError('Applet output schema must be a JSON Schema object.'); + } + + return source; +} + function formatSandboxError(code, report) { const errName = report.errorName || 'Error'; const msg = report.error || 'Unknown error'; @@ -122,7 +186,10 @@ export function createJavascriptExecuteTool({ googleAuthId }) { return defineTool({ name: 'javascript_execute', description: - 'Execute JS in isolated worker sandbox. Assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' + + 'Execute JS in an isolated worker sandbox using either inline `code` or a VFS applet. Provide exactly one of `code` and `appletvfsfile`. ' + + 'Applets are the preferred method when the first execution may throw: save a `.applet.js` file in the VFS so it can be inspected and further edited with `patch`, then call it by VFS path. ' + + 'Applet files must start with `// @applet-input-schema {JSON Schema}` and `// @applet-output-schema {JSON Schema}` on their first two lines. The input schema describes the GUI form passed as `applet_input`; the output schema describes the exported function result. Export a function that receives `applet_input`. ' + + 'Inline code: assign `module.exports` to return value (sync value or Promise; wrap async in IIFE: `module.exports = (async () => { ... })()`). ' + 'REST API: `await fetch("/path")` (authenticated). ' + 'VFS: directly use global `fs.readFileSync`/`writeFileSync` for binary artifacts/buffers/images (`image/` is read-only; paths must be relative paths, root is not allowed). VFS can host a static website (HTML, CSS, JS, images, etc.). ' + 'Embed generated images/links in markdown: `![alt](relative/path)`. Internet links use `https://...` or `hostname.com`/`hostname.com/filename`; a pure relative file with no dotted hostname is always a VFS relative path. Use find_files/read_file/patch for text. ' + @@ -131,7 +198,6 @@ export function createJavascriptExecuteTool({ googleAuthId }) { parameters: { type: 'object', additionalProperties: false, - required: ['code'], properties: { note: { type: ['string', 'null'], @@ -139,7 +205,16 @@ export function createJavascriptExecuteTool({ googleAuthId }) { }, code: { type: 'string', - description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.', + description: 'Optional inline JavaScript code. Provide exactly one of this and appletvfsfile. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.', + }, + appletvfsfile: { + type: 'string', + description: 'Optional VFS-relative path to a .applet.js file. Provide exactly one of this and code. The file must begin with the two applet JSON Schema metadata lines described in the tool description.', + }, + applet_input: { + type: 'object', + description: 'Input values passed to the exported function when running appletvfsfile. Omit to pass an empty object.', + additionalProperties: true, }, timeout: { type: ['number', 'null'], @@ -151,17 +226,48 @@ export function createJavascriptExecuteTool({ googleAuthId }) { }, }, }, - invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, note }) => { - if (typeof code !== 'string' || !code.trim()) { - throw new ToolError('"code" must be a non-empty string.'); + invoke: async ({ + code, + appletvfsfile, + applet_input, + timeout = DEFAULT_TIMEOUT_MS, + max_output = DEFAULT_MAX_OUTPUT, + note, + }) => { + const hasCode = typeof code === 'string' && Boolean(code.trim()); + const hasApplet = typeof appletvfsfile === 'string' && Boolean(appletvfsfile.trim()); + if (hasCode === hasApplet) { + throw new ToolError('Provide exactly one of "code" or "appletvfsfile".'); + } + if (code != null && !hasCode) { + throw new ToolError('"code" must be a non-empty string when provided.'); + } + if (appletvfsfile != null && !hasApplet) { + throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.'); + } + if (hasCode && applet_input !== undefined) { + throw new ToolError('"applet_input" can only be provided with "appletvfsfile".'); + } + if (applet_input !== undefined && + (!applet_input || typeof applet_input !== 'object' || Array.isArray(applet_input))) { + throw new ToolError('"applet_input" must be an object.'); } const hardTimeout = Math.min(Math.max(1000, Number(timeout) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS); const maxOutput = Math.max(1000, Number(max_output) || DEFAULT_MAX_OUTPUT); const vfsRoot = await prepareVfs({ googleAuthId }); + const isApplet = hasApplet; + const source = isApplet ? readApplet(vfsRoot, appletvfsfile) : code; let report; try { - report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId }); + report = await runInWorker({ + code: source, + timeoutMs: hardTimeout, + vfsRoot, + googleAuthId, + appletInput: isApplet ? (applet_input ?? {}) : undefined, + appletFile: isApplet ? appletvfsfile.trim() : undefined, + }); } finally { await syncVfsToDb({ googleAuthId, vfsRoot }); } @@ -173,7 +279,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) { } if (report.error) { - throw new ToolError(formatSandboxError(code, report)); + throw new ToolError(formatSandboxError(source, report)); } // The worker ALWAYS reports a `result` key (javascript-worker.js). Its absence