Compare commits
3 Commits
a162feefbb
...
e5ff4ef307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5ff4ef307 | ||
|
|
9a7f345d8b | ||
|
|
1786879c4e |
@@ -18,7 +18,7 @@ ESM (`type: module`), Express 5, Node. Depends on `libdashstore` via `file:../da
|
||||
```bash
|
||||
cp .env.example .env && npm install
|
||||
npm start # PORT 4208 (HTTPS 8443)
|
||||
npm run docs:api # regenerates docs/api/routes.md + docs/api/routes/ + openapi.json
|
||||
npm run docs:api # regenerates docs/api/routes.md + docs/api/routes-all.md + docs/api/routes/ + openapi.json
|
||||
npm run manage-users # email allowlist
|
||||
```
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -29,7 +29,7 @@ Docs are generated from `@openapi` JSDoc on route handlers:
|
||||
npm run docs:api
|
||||
```
|
||||
|
||||
That writes `docs/api/routes.md` and per-route Markdown under `docs/api/routes/`.
|
||||
That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdown under `docs/api/routes/`.
|
||||
|
||||
### Brands (company-scoped, `selectedCompanyId` aus der Session)
|
||||
|
||||
@@ -55,6 +55,14 @@ That writes `docs/api/routes.md` and per-route Markdown under `docs/api/routes/`
|
||||
| `PATCH` | `/api/category/:id` | `category.rename` und/oder `category.move` (Name+Slug und/oder Verschieben unter neuen Eltern) |
|
||||
| `PUT` | `/api/category/order` | `category.order` (Geschwister-Sortierung unter einem Elternteil) |
|
||||
|
||||
### Public Product Catalog (ohne Auth, company-scoped)
|
||||
|
||||
| Methode | Route | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/public/:companyId/categories` | Liefert den Kategoriebaum der Firma |
|
||||
| `GET` | `/api/public/:companyId/articles` | Liefert paginierte Artikelliste (optional gefiltert nach `?categoryId=`, `?search=`) |
|
||||
| `GET` | `/api/public/:companyId/articles/:sku` | Liefert öffentliche Artikeldetails (Preise, Bilder, Kategorien, EANs, Varianten) |
|
||||
|
||||
## Users
|
||||
|
||||
Manage the email allowlist:
|
||||
|
||||
@@ -144,6 +144,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/ledger.post.md`](routes/ledger.post.md) | `POST /ledger` |
|
||||
| [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` |
|
||||
| [`routes/ledger/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` |
|
||||
| [`routes/modelconversation.get.md`](routes/modelconversation.get.md) | `GET /modelconversation` |
|
||||
| [`routes/modelconversation.post.md`](routes/modelconversation.post.md) | `POST /modelconversation` |
|
||||
| [`routes/modelconversation/_id.delete.md`](routes/modelconversation/_id.delete.md) | `DELETE /modelconversation/{id}` |
|
||||
| [`routes/modelconversation/_id.get.md`](routes/modelconversation/_id.get.md) | `GET /modelconversation/{id}` |
|
||||
| [`routes/modelconversation/_id/message.get.md`](routes/modelconversation/_id/message.get.md) | `GET /modelconversation/{id}/message` |
|
||||
| [`routes/modelconversation/_id/message.post.md`](routes/modelconversation/_id/message.post.md) | `POST /modelconversation/{id}/message` |
|
||||
| [`routes/modelconversation/_id/stream.md`](routes/modelconversation/_id/stream.md) | `GET /modelconversation/{id}/stream` |
|
||||
| [`routes/number/list.md`](routes/number/list.md) | `GET /number` |
|
||||
| [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` |
|
||||
| [`routes/order/list.md`](routes/order/list.md) | `GET /order` |
|
||||
@@ -170,6 +177,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/propertyname/order.md`](routes/propertyname/order.md) | `PUT /propertyname/order` |
|
||||
| [`routes/propertyname/_id.delete.md`](routes/propertyname/_id.delete.md) | `DELETE /propertyname/{id}` |
|
||||
| [`routes/propertyname/_id.patch.md`](routes/propertyname/_id.patch.md) | `PATCH /propertyname/{id}` |
|
||||
| [`routes/public/_companyId/articles.md`](routes/public/_companyId/articles.md) | `GET /public/{companyId}/articles` |
|
||||
| [`routes/public/_companyId/articles/_sku.md`](routes/public/_companyId/articles/_sku.md) | `GET /public/{companyId}/articles/{sku}` |
|
||||
| [`routes/public/_companyId/categories.md`](routes/public/_companyId/categories.md) | `GET /public/{companyId}/categories` |
|
||||
| [`routes/quote/list.md`](routes/quote/list.md) | `GET /quote` |
|
||||
| [`routes/quote/create.md`](routes/quote/create.md) | `POST /quote` |
|
||||
| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` |
|
||||
@@ -192,6 +202,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
|
||||
| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` |
|
||||
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
|
||||
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` |
|
||||
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
|
||||
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
|
||||
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |
|
||||
@@ -200,7 +211,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/warehouse/update.md`](routes/warehouse/update.md) | `PATCH /warehouse/{id}` |
|
||||
| [`routes/warehouse/_id.put.md`](routes/warehouse/_id.put.md) | `PUT /warehouse/{id}` |
|
||||
|
||||
Route overview: [`routes.md`](routes.md).
|
||||
Route overview: [`routes.md`](routes.md) | All routes: [`routes-all.md`](routes-all.md).
|
||||
|
||||
## Signals (SSE)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
192
docs/api/routes-all.md
Normal file
192
docs/api/routes-all.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 178 API endpoints across all modules.
|
||||
|
||||
### Parameter Notation
|
||||
|
||||
- **Required parameters** have no `?` (e.g. `sku:string`, `categoryId`, `query: si:string`, `body: { name }`).
|
||||
- **Optional parameters** are marked with `?` (e.g. `query: categoryId?:integer`, `track_stock?`, `body?: ...`).
|
||||
- **Path parameters** match `{param}` in the route URL.
|
||||
- **Query parameters** are prefixed with `query: ...`.
|
||||
- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if optional).
|
||||
|
||||
| Method | Route | Params | Return Type | Summary |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| `POST` | `/article` | `body: { categoryId, name, track_stock? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Create article in category |
|
||||
| `GET` | `/article/export.zip` | `query: categoryId?:integer` | `200 zip` | Export articles as ZIP |
|
||||
| `DELETE` | `/article/{sku}` | `sku:string, query: cascade?:boolean` | `204 void` | Delete article by SKU |
|
||||
| `GET` | `/article/{sku}` | `sku:string` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Get article by SKU |
|
||||
| `PATCH` | `/article/{sku}` | `sku:string, body: { name?, unitamount?, unitSi?, track_stock? }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Update article by SKU |
|
||||
| `GET` | `/article/{sku}/articlelist` | `sku:string` | `200 { items: { id, articleId, sku, name, count, sort }[] }` | Get Stückliste (bundle items) for an article |
|
||||
| `PUT` | `/article/{sku}/articlelist` | `sku:string, body: { items: { articleId?, sku?: string, count?, sort? }[] }` | `200 { items: object[] }` | Set Stückliste (bundle items) for an article |
|
||||
| `GET` | `/article/{sku}/attributevalue` | `sku:string` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List article attribute values |
|
||||
| `PUT` | `/article/{sku}/attributevalue` | `sku:string, body: { attributevalues: any[] }` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set article attribute values |
|
||||
| `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set or clear article brand by SKU |
|
||||
| `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article categories by SKU |
|
||||
| `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article |
|
||||
| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article |
|
||||
| `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article |
|
||||
| `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article EANs by SKU |
|
||||
| `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article |
|
||||
| `GET` | `/article/{sku}/history` | `sku:string, query: limit?:integer` | `200 { history: { id, date, text }[], truncated }` | Get article history by SKU |
|
||||
| `POST` | `/article/{sku}/image` | `sku:string, image/avif` | `201 { image: { picId, sort, full, thumb, hash, thumbHash } }` | Add article image |
|
||||
| `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image |
|
||||
| `PUT` | `/article/{sku}/images/order` | `sku:string, body: { fullHashes: string[] }` | `200 { fullHashes: string[] }` | Reorder article images |
|
||||
| `GET` | `/article/{sku}/optionslist` | `sku:string` | `200 { options: { id, articleId, sku, name, count, sort, mandated }[] }` | Get options list for an article |
|
||||
| `PUT` | `/article/{sku}/optionslist` | `sku:string, body: { options: { articleId?, sku?: string, count?, sort?, mandated? }[] }` | `200 { options: object[] }` | Set options list for an article |
|
||||
| `PUT` | `/article/{sku}/prices` | `sku:string, body: { prices: { price, tax, minmultiplier? }[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace POS prices by SKU |
|
||||
| `POST` | `/article/{sku}/print` | `sku:string, body: { templateId, printerId, quantity }` | `200` | Print article label |
|
||||
| `GET` | `/article/{sku}/property` | `sku:string` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | List article properties |
|
||||
| `PUT` | `/article/{sku}/property` | `sku:string, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | Bulk set article properties |
|
||||
| `DELETE` | `/article/{sku}/property/{propertyId}` | `sku:string, propertyId:integer` | `200` | Delete article property |
|
||||
| `GET` | `/article/{sku}/supplier` | `sku:string` | `200 { suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] }` | List article suppliers |
|
||||
| `POST` | `/article/{sku}/supplier` | `sku:string, body: { entityId, sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `201 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Assign supplier to article |
|
||||
| `PUT` | `/article/{sku}/supplier/order` | `sku:string, body: { ids: integer[] }` | `200 { ids: integer[] }` | Reorder article suppliers |
|
||||
| `DELETE` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer` | `204 void` | Remove supplier assignment from article |
|
||||
| `PATCH` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer, body: { sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `200 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Update article supplier assignment |
|
||||
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article track_stock flag by SKU |
|
||||
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article weight by SKU |
|
||||
| `GET` | `/articleIndex` | `query: windowSize:integer` | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[], truncated }` | List article search index |
|
||||
| `GET` | `/attributename` | — | `200 { attributenames: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] }[] }` | List attribute names |
|
||||
| `POST` | `/attributename` | `body: { name, sort?, exclusive? }` | `201 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Create attribute name |
|
||||
| `PUT` | `/attributename/order` | `body: { attributenameIds: integer[] }` | `200` | Reorder attribute names |
|
||||
| `GET` | `/attributename/{attributeId}/value` | `attributeId:integer` | `200 { values: { id, attributeId, value, sort }[] }` | List values for an attribute |
|
||||
| `POST` | `/attributename/{attributeId}/value` | `attributeId:integer, body: { value, sort? }` | `201 { attributevalue: { id, attributeId, value, sort } }` | Create value for an attribute |
|
||||
| `PUT` | `/attributename/{attributeId}/value/order` | `attributeId:integer, body: { attributevalueIds: integer[] }` | `200` | Reorder values of an attribute |
|
||||
| `DELETE` | `/attributename/{id}` | `id:integer` | `200` | Delete attribute name |
|
||||
| `PATCH` | `/attributename/{id}` | `id:integer, body: { name?, sort?, exclusive? }` | `200 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Update attribute name |
|
||||
| `DELETE` | `/attributevalue/{id}` | `id:integer` | `200` | Delete attribute value |
|
||||
| `PATCH` | `/attributevalue/{id}` | `id:integer, body: { value?, sort? }` | `200 { attributevalue: { id, attributeId, value, sort } }` | Update attribute value |
|
||||
| `POST` | `/auth/login` | `body: { idToken: string }` | `200 { message, user }` | Login with Google ID token |
|
||||
| `POST` | `/auth/logout` | — | `200 { message: string }` | Logout |
|
||||
| `GET` | `/auth/me` | — | `200 { user, googleClientId }` | Current user |
|
||||
| `PUT` | `/auth/selected-company` | `body: { companyId: integer }` | `200 { user: { id, email, selectedCompanyId, picture } }` | Set selected company |
|
||||
| `GET` | `/brand` | — | `200 { brands: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] }[] }` | List brands |
|
||||
| `PUT` | `/brand` | `body: { name, gsprpinfo? }` | `200 { brand: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] } }` | Upsert brand |
|
||||
| `DELETE` | `/brand/{id}` | `id:integer` | `204 void` | Delete brand |
|
||||
| `POST` | `/category` | `body: { parentId, name }` | `201 { category: { id, parentId, sort, name } }` | Create category |
|
||||
| `GET` | `/category/export.zip` | — | `200 zip` | Export categories as ZIP |
|
||||
| `PUT` | `/category/order` | `body: { parentId, categoryIds: integer[] }` | `200 { categoryIds: integer[] }` | Reorder sibling categories |
|
||||
| `GET` | `/category/tree` | — | `200 { languageId, tree: object[], articles: object[] }` | Category tree |
|
||||
| `DELETE` | `/category/{id}` | `id:integer` | `204 void` | Delete category |
|
||||
| `PATCH` | `/category/{id}` | `id:integer, body: { name?, parentId? }` | `200 { category: { id, name, slug, parentId, sort } }` | Update category (rename or move) |
|
||||
| `GET` | `/category/{id}/articles` | `id:integer` | `200 { articles: { id, sku, name, slug, net, taxRate, track_stock, optionsCount, articleListCount, childCount, fullHash, thumbW, thumbH, thumbHash, brand }[] }` | Articles in category |
|
||||
| `GET` | `/category/{id}/attributevalue` | `id:integer` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List category attribute values |
|
||||
| `PUT` | `/category/{id}/attributevalue` | `id:integer, body: { attributevalues: any[] }` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set category attribute values |
|
||||
| `DELETE` | `/category/{id}/image` | `id:integer` | `200 { category: { id, thumbW, thumbH, thumbHash } }` | Delete category image |
|
||||
| `POST` | `/category/{id}/image` | `id:integer, image/avif` | `201 { category: { id, thumbW, thumbH, thumbHash } }` | Add category image |
|
||||
| `GET` | `/category/{id}/property` | `id:integer` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | List category properties |
|
||||
| `PUT` | `/category/{id}/property` | `id:integer, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | Bulk set category properties |
|
||||
| `DELETE` | `/category/{id}/property/{propertyId}` | `id:integer, propertyId:integer` | `200` | Delete category property |
|
||||
| `GET` | `/company` | — | `200 { companies: { id, iso639, name, taxrate, reducedtaxrate }[] }` | List companies |
|
||||
| `POST` | `/company` | `body: { iso639, name, taxrate, reducedtaxrate }` | `201 { company, user }` | Create company |
|
||||
| `GET` | `/company/setting` | — | `200 { settings: { key, value }[] }` | List company settings |
|
||||
| `GET` | `/company/setting/{key}` | `key:string` | `200 { setting: { key, value } }` | Get company setting by key |
|
||||
| `PUT` | `/company/setting/{key}` | `key:string, body: { value, frontend? }` | `200 { setting: { key, value } }` | Upsert company setting |
|
||||
| `DELETE` | `/company/{id}` | `id:integer` | `204 void` | Delete company |
|
||||
| `PUT` | `/company/{id}` | `id:integer, body: { iso639, name, taxrate, reducedtaxrate }` | `200 { company: { id, iso639, name, taxrate, reducedtaxrate } }` | Update company |
|
||||
| `GET` | `/customergroup` | — | `200 { customergroups: { id, name, sort, editable }[] }` | List customer groups |
|
||||
| `POST` | `/customergroup` | `body: { name: string }` | `201 { customergroup: { id, name, sort } }` | Create customer group |
|
||||
| `PUT` | `/customergroup/order` | `body: { customergroupIds: integer[] }` | `200 { customergroupIds: integer[] }` | Reorder customer groups |
|
||||
| `DELETE` | `/customergroup/{id}` | `id:integer` | `204 void` | Delete customer group |
|
||||
| `PATCH` | `/customergroup/{id}` | `id:integer, body: { name: string }` | `200 { customergroup: { id, name, sort } }` | Update customer group |
|
||||
| `GET` | `/delivery` | — | `200 { deliveries: { id, number, type, supplierId, externalNumber, date, createdAt, ledger }[] }` | List deliveries |
|
||||
| `POST` | `/delivery` | `body: { externalNumber, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], supplierId?, files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `201 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Create delivery |
|
||||
| `POST` | `/delivery/parse` | `form: { file: string }` | `200 SSE` | Parse document (SSE) |
|
||||
| `DELETE` | `/delivery/{id}` | `id:integer` | `204 void` | Delete delivery |
|
||||
| `GET` | `/delivery/{id}` | `id:integer` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Get delivery |
|
||||
| `PATCH` | `/delivery/{id}` | `id:integer, body: { externalNumber?, date?, supplierId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Update delivery |
|
||||
| `GET` | `/delivery/{id}/file/{fileId}` | `id:integer, fileId:integer, query: download?:boolean` | `200 string` | Get delivery attachment file content |
|
||||
| `GET` | `/entity` | `query: customergroupId?:integer, role?:string` | `200 { entities: { id, customergroupId, firstName, name, number, createdAt, updatedAt }[] }` | List entities |
|
||||
| `POST` | `/entity` | `body: { customergroupId, name, firstName?, number? }` | `201 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Create entity |
|
||||
| `GET` | `/entity/next-number` | — | `200 { number: string }` | Peek next customer number |
|
||||
| `GET` | `/entity/{entityId}/address` | `entityId:integer` | `200 { addresses: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType }[] }` | List entity addresses |
|
||||
| `POST` | `/entity/{entityId}/address` | `entityId:integer, body: { addressType, firstName?, name?, street?, streetNumber?, zip?, city?, county? }` | `201 { address: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType } }` | Create entity address |
|
||||
| `DELETE` | `/entity/{entityId}/address/{id}` | `entityId:integer, id:integer` | `204 void` | Delete entity address |
|
||||
| `PATCH` | `/entity/{entityId}/address/{id}` | `entityId:integer, id:integer, body: { addressType?, firstName?, name?, street?, streetNumber?, zip?, city?, county? }` | `200 { address: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType } }` | Update entity address |
|
||||
| `GET` | `/entity/{entityId}/contact` | `entityId:integer` | `200 { contacts: { id, entityId, contactType, contact }[] }` | List entity contacts |
|
||||
| `POST` | `/entity/{entityId}/contact` | `entityId:integer, body: { contactType, contact }` | `201 { contact: { id, entityId, contactType, contact } }` | Create entity contact |
|
||||
| `DELETE` | `/entity/{entityId}/contact/{id}` | `entityId:integer, id:integer` | `204 void` | Delete entity contact |
|
||||
| `PATCH` | `/entity/{entityId}/contact/{id}` | `entityId:integer, id:integer, body: { contactType?, contact? }` | `200 { contact: { id, entityId, contactType, contact } }` | Update entity contact |
|
||||
| `DELETE` | `/entity/{id}` | `id:integer` | `204 void` | Delete entity |
|
||||
| `GET` | `/entity/{id}` | `id:integer` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Get entity |
|
||||
| `PATCH` | `/entity/{id}` | `id:integer, body: { name?, firstName?, number? }` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Update entity |
|
||||
| `GET` | `/events` | — | `200 SSE` | Subscribe to update signals (SSE) |
|
||||
| `GET` | `/health` | — | `200 { ok: boolean }` | Database health check |
|
||||
| `GET` | `/image/{fullHash}` | `fullHash:string` | `200 avif` | Full image by hash |
|
||||
| `GET` | `/labeltemplate` | — | `200 { labeltemplates: { id, name, width, height, dpi, type, content, thumb }[] }` | List label templates |
|
||||
| `POST` | `/labeltemplate` | `body: { name, width, height, dpi, type, content }` | `201 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Create label template |
|
||||
| `DELETE` | `/labeltemplate/{id}` | `id:integer` | `204 void` | Delete label template |
|
||||
| `GET` | `/labeltemplate/{id}` | `id:integer` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Get label template by ID |
|
||||
| `PATCH` | `/labeltemplate/{id}` | `id:integer, body: { name?, width?, height?, dpi?, type?, content? }` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Update label template (partial) |
|
||||
| `PUT` | `/labeltemplate/{id}` | `id:integer, body: { name?, width?, height?, dpi?, type?, content? }` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Update label template |
|
||||
| `GET` | `/ledger` | `query: manifestarticle_id?:integer, sku?:string, transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by manifest article ID or SKU |
|
||||
| `POST` | `/ledger` | `body: { manifestarticle_id, transaction_type, amount, transaction_text }` | `201 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at } }` | Add ledger entry |
|
||||
| `GET` | `/ledger/manifestarticle/{manifestarticleId}` | `manifestarticleId:integer, query: transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by manifest article ID |
|
||||
| `GET` | `/ledger/sku/{sku}` | `sku:string, query: transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by article SKU |
|
||||
| `GET` | `/modelconversation` | — | `200 { conversations: { id, companyuser_id, provider, model, created_at, updated_at, state }[] }` | List conversations |
|
||||
| `POST` | `/modelconversation` | `body: { provider, model }` | `201 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } }` | Create conversation |
|
||||
| `DELETE` | `/modelconversation/{id}` | `id:integer` | `204 void` | Delete conversation |
|
||||
| `GET` | `/modelconversation/{id}` | `id:integer` | `200 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } }` | Get conversation |
|
||||
| `GET` | `/modelconversation/{id}/message` | `id:integer` | `200 { messages: { id, conversation_id, created_at, role, message }[] }` | List conversation messages |
|
||||
| `POST` | `/modelconversation/{id}/message` | `id:integer, body: { message?, role?, tool_call_id?, content?, answer? }` | `201 { message: { id, conversation_id, created_at, role, message } }` | Post a user message |
|
||||
| `GET` | `/modelconversation/{id}/stream` | `id:integer` | `200 SSE` | Stream conversation events (SSE) |
|
||||
| `GET` | `/number` | — | `200 { numbers: { id, type, prefix, padding, next }[] }` | List number sequences |
|
||||
| `PATCH` | `/number/{id}` | `id:integer, body: { prefix?, padding?, next? }` | `200 { number: { id, type, prefix, padding, next } }` | Update number sequence |
|
||||
| `GET` | `/order` | — | `200 { orders: { id, number, type, entityId, date, createdAt }[] }` | List orders |
|
||||
| `POST` | `/order` | `body: { number, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], entityId? }` | `201 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Create order |
|
||||
| `DELETE` | `/order/{id}` | `id:integer` | `204 void` | Delete order |
|
||||
| `GET` | `/order/{id}` | `id:integer` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get order |
|
||||
| `PATCH` | `/order/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update order |
|
||||
| `GET` | `/posterminal` | — | `200 { posterminals: { id, name, pairingpin, categoryIds: integer[] }[] }` | List POS terminals |
|
||||
| `PUT` | `/posterminal` | `body: { name: string }` | `200 { posterminal: { id, name, pairingpin, categoryIds: integer[] } }` | Create POS terminal |
|
||||
| `DELETE` | `/posterminal/{id}` | `id:integer` | `204 void` | Delete POS terminal |
|
||||
| `DELETE` | `/posterminal/{id}/all` | `id:integer` | `200 { id, categoryIds: integer[] }` | Disable all categories on terminal |
|
||||
| `PUT` | `/posterminal/{id}/all` | `id:integer` | `200 { id, categoryIds: integer[] }` | Enable all categories on terminal |
|
||||
| `DELETE` | `/posterminal/{id}/category/{categoryId}` | `id:integer, categoryId:integer` | `200 { id, categoryIds: integer[] }` | Disable category on terminal |
|
||||
| `PUT` | `/posterminal/{id}/category/{categoryId}` | `id:integer, categoryId:integer` | `200 { id, categoryIds: integer[] }` | Enable category on terminal |
|
||||
| `GET` | `/printer` | — | `200 { printers: { id, company_id, address, type, model, serial, sort, desc }[] }` | List printers |
|
||||
| `POST` | `/printer` | `body: { address, type, model?, serial?, sort?, desc? }` | `201 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Create printer |
|
||||
| `PUT` | `/printer/order` | `body: { printerIds: integer[] }` | `200 { printerIds: integer[] }` | Reorder printers |
|
||||
| `DELETE` | `/printer/{id}` | `id:integer` | `204 void` | Delete printer |
|
||||
| `GET` | `/printer/{id}` | `id:integer` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Get printer by ID |
|
||||
| `PATCH` | `/printer/{id}` | `id:integer, body: { address?, type?, model?, serial?, sort?, desc? }` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Update printer (partial) |
|
||||
| `PUT` | `/printer/{id}` | `id:integer, body: { address?, type?, model?, serial?, sort?, desc? }` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Update printer |
|
||||
| `GET` | `/propertyname` | — | `200 { propertynames: { id, name, sort, exclusive }[] }` | List property names |
|
||||
| `POST` | `/propertyname` | `body: { name, sort?, exclusive? }` | `201 { propertyname: { id, name, sort, exclusive } }` | Create property name |
|
||||
| `PUT` | `/propertyname/order` | `body: { propertynameIds: integer[] }` | `200` | Reorder property names |
|
||||
| `DELETE` | `/propertyname/{id}` | `id:integer` | `200` | Delete property name |
|
||||
| `PATCH` | `/propertyname/{id}` | `id:integer, body: { name?, sort?, exclusive? }` | `200 { propertyname: { id, name, sort, exclusive } }` | Update property name |
|
||||
| `GET` | `/public/{companyId}/articles` | `companyId:integer, query: categoryId?:integer, search?:string, limit?:integer, offset?:integer` | `200 { articles: { id, sku, name, slug, net, taxRate, gross, trackStock, brand, fullHash, thumbW, thumbH, thumbHash }[] }` | List public articles |
|
||||
| `GET` | `/public/{companyId}/articles/{sku}` | `companyId:integer, sku:string` | `200 { article: { id, sku, name, slug, description, unitamount, weight, trackStock, unit, brand, price, prices: object[], categories: object[], photos: object[], eans: string[], isFather, fatherSku, variants: object[] } }` | Get public article detail |
|
||||
| `GET` | `/public/{companyId}/categories` | `companyId:integer` | `200 { categories: { id, parentId, sort, name, slug, fullHash, thumbW, thumbH, thumbHash }[] }` | List public categories |
|
||||
| `GET` | `/quote` | — | `200 { quotes: { id, number, type, entityId, date, createdAt }[] }` | List quotes |
|
||||
| `POST` | `/quote` | `body: { number, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], entityId? }` | `201 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Create quote |
|
||||
| `DELETE` | `/quote/{id}` | `id:integer` | `204 void` | Delete quote |
|
||||
| `GET` | `/quote/{id}` | `id:integer` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get quote |
|
||||
| `PATCH` | `/quote/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update quote |
|
||||
| `POST` | `/serper/image` | `body: { url: string }` | `200 avif` | Crawl image from URL, crop white/transparency borders, and return AVIF |
|
||||
| `POST` | `/serper/images` | `body: { q, gl?, hl?, num?, preferSource? }` | `200 SSE` | Search images via Serper.dev and stream WebP thumbnails (SSE) |
|
||||
| `POST` | `/stock/adjust` | `body: { sku?: string, warehouseId?, amount?, text?, lines?: { sku: string, amount, warehouseId?, text? }[] }` | `201 { manifest, lines: object[], ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Adjust stock manually |
|
||||
| `GET` | `/supplier` | — | `200 { suppliers: { id, name, createdAt, updatedAt }[] }` | List suppliers |
|
||||
| `POST` | `/supplier` | `body: { name: string }` | `201 { supplier: { id, name, createdAt, updatedAt } }` | Create supplier |
|
||||
| `DELETE` | `/supplier/{id}` | `id:integer` | `204 void` | Delete supplier |
|
||||
| `PATCH` | `/supplier/{id}` | `id:integer, body: { name: string }` | `200 { supplier: { id, name, createdAt, updatedAt } }` | Update supplier |
|
||||
| `GET` | `/supplier/{id}/articles` | `id:integer, query: skus?` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | List supplier articles by SKUs |
|
||||
| `POST` | `/supplier/{id}/articles` | `id:integer, body?: { skus?: string[] }` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | Query supplier articles by SKUs (bulk) |
|
||||
| `GET` | `/thumb/{fullHash}` | `fullHash:string` | `200 avif` | Thumb image by hash |
|
||||
| `DELETE` | `/unit` | `query: si:string` | `204 void` | Delete unit |
|
||||
| `GET` | `/unit` | — | `200 { units: { si, basesi, basesifactor, symbol, name }[] }` | List units |
|
||||
| `PUT` | `/unit` | `body: { si, basesi, basesifactor, symbol, name }` | `200 { unit: { si, basesi, basesifactor, symbol, name } }` | Upsert unit |
|
||||
| `GET` | `/v1/category` | `query: lastChangedCategory?:string, limit?:integer` | `200 object[]` | JTL-POS category synchronization |
|
||||
| `GET` | `/v1/client` | `query: authCode:string, name?:string` | `200 { authCode, authToken, certificateFingerprint, certificateSerialNumber, mandantId, mandantName, mandantDatabase, serverFingerprint, name, serverTimestamp }` | POS discovery and pairing |
|
||||
| `GET` | `/v1/init` | `query: lastChangedProduct?:integer, lastChangedCategory?:integer, lastChangedCustomer?:integer, lastChangedCustomerGroup?:integer, lastChangedCompositeProduct?:integer, lastChangedDeletedEntity?:integer` | `200 { version, product_count, category_count, customer_count, customerGroup_count, compositeProduct_count, configurationGroup_count, configurationItem_count, deletedEntity_count, max_orderId_count }` | POS sync initialization counts |
|
||||
| `GET` | `/v1/product` | `query: lastChangedProduct?:string, limit?:integer` | `200 object[]` | JTL-POS product catalog synchronization |
|
||||
| `GET` | `/vfs/{*path}` | `path:string, query: download?:boolean` | `200 string` | Get a virtual filesystem file |
|
||||
| `GET` | `/warehouse` | — | `200 { warehouses: { id, name, sort }[] }` | List warehouses |
|
||||
| `POST` | `/warehouse` | `body: { name, sort? }` | `201 { warehouse: { id, name, sort } }` | Create warehouse |
|
||||
| `PUT` | `/warehouse/order` | `body: { warehouseIds: integer[] }` | `200 { warehouseIds: integer[] }` | Reorder warehouses |
|
||||
| `DELETE` | `/warehouse/{id}` | `id:integer` | `204 void` | Delete warehouse |
|
||||
| `GET` | `/warehouse/{id}` | `id:integer` | `200 { warehouse: { id, name, sort } }` | Get warehouse by ID |
|
||||
| `PATCH` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse (partial) |
|
||||
| `PUT` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview
|
||||
|
||||
Overview of all 141 API endpoints across all modules.
|
||||
Overview of all 142 API endpoints across all modules.
|
||||
|
||||
### Parameter Notation
|
||||
|
||||
@@ -44,7 +44,7 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `PATCH` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer, body: { sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `200 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Update article supplier assignment |
|
||||
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article track_stock flag by SKU |
|
||||
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article weight by SKU |
|
||||
| `GET` | `/articleIndex` | — | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[] }` | List article search index |
|
||||
| `GET` | `/articleIndex` | `query: windowSize:integer` | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[], truncated }` | List article search index |
|
||||
| `GET` | `/attributename` | — | `200 { attributenames: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] }[] }` | List attribute names |
|
||||
| `POST` | `/attributename` | `body: { name, sort?, exclusive? }` | `201 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Create attribute name |
|
||||
| `PUT` | `/attributename/order` | `body: { attributenameIds: integer[] }` | `200` | Reorder attribute names |
|
||||
@@ -146,6 +146,7 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `DELETE` | `/unit` | `query: si:string` | `204 void` | Delete unit |
|
||||
| `GET` | `/unit` | — | `200 { units: { si, basesi, basesifactor, symbol, name }[] }` | List units |
|
||||
| `PUT` | `/unit` | `body: { si, basesi, basesifactor, symbol, name }` | `200 { unit: { si, basesi, basesifactor, symbol, name } }` | Upsert unit |
|
||||
| `GET` | `/vfs/{*path}` | `path:string, query: download?:boolean` | `200 string` | Get a virtual filesystem file |
|
||||
| `GET` | `/warehouse` | — | `200 { warehouses: { id, name, sort }[] }` | List warehouses |
|
||||
| `POST` | `/warehouse` | `body: { name, sort? }` | `201 { warehouse: { id, name, sort } }` | Create warehouse |
|
||||
| `PUT` | `/warehouse/order` | `body: { warehouseIds: integer[] }` | `200 { warehouseIds: integer[] }` | Reorder warehouses |
|
||||
@@ -153,3 +154,26 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `GET` | `/warehouse/{id}` | `id:integer` | `200 { warehouse: { id, name, sort } }` | Get warehouse by ID |
|
||||
| `PATCH` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse (partial) |
|
||||
| `PUT` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse |
|
||||
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
|
||||
|
||||
These routes are NOT in the table above because their responses are binary or long-lived streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write them to the VFS and embed them in your reply:
|
||||
|
||||
| Method | Route | Returns | Notes |
|
||||
|:---|:---|:---|:---|
|
||||
| `GET` | `/image/{fullHash}` | `image/avif` | Full-size article/brand/category image by SHA-256 hash. Use for inspecting or re-encoding an existing image.
|
||||
| `GET` | `/thumb/{fullHash}` | `image/avif` | Thumb AVIF by SHA-256 hash — cheaper than the full image. Takes the same `fullHash` as `/image/{fullHash}`.
|
||||
| `POST` | `/serper/image` | `image/avif` | Crawl an image from a URL, auto-crop transparent/white borders. Body: `{ url }`.
|
||||
|
||||
Save the bytes to the VFS, then embed them in your reply as ``:
|
||||
|
||||
```js
|
||||
module.exports = (async () => {
|
||||
const hash = "…64-char sha-256…";
|
||||
const res = await fetch(`/thumb/${hash}`); // authenticated automatically
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
fs.writeFileSync("artifacts/thumb.avif", buf);
|
||||
return { bytes: buf.length };
|
||||
})();
|
||||
```
|
||||
|
||||
`fullHash` appears on articles, brands and categories. `thumbHash` is a base64 blur-up placeholder, not a content hash, and is not accepted here.
|
||||
|
||||
@@ -8,6 +8,20 @@ Returns every article reachable via the company's category tree.
|
||||
Designed for client-side search / autocomplete preload.
|
||||
Auth via company access.
|
||||
|
||||
REQUIRES `windowSize`. Without it the response is always an empty list — the full
|
||||
index can be very large and is not useful to a caller that did not ask for a
|
||||
specific window. Pass the number of entries the client actually intends to preload.
|
||||
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `windowSize` | yes | integer | Number of articles to return (window size). Required; omitting it returns an
|
||||
empty `articles` array. `truncated` is true when more entries exist than were
|
||||
returned, signalling the client should request a larger window or narrow its
|
||||
query.
|
||||
|
|
||||
|
||||
## Response `200`
|
||||
|
||||
@@ -27,7 +41,8 @@ Search index
|
||||
1
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"truncated": true
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List label templates
|
||||
|
||||
Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. `content.objects` is omitted to reduce payload size.
|
||||
Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. The `content` field is omitted to reduce payload size (use GET /labeltemplate/{id} for it).
|
||||
|
||||
## Response `200`
|
||||
|
||||
|
||||
39
docs/api/routes/modelconversation.get.md
Normal file
39
docs/api/routes/modelconversation.get.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# `GET /modelconversation`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List conversations
|
||||
|
||||
Lists the authenticated user's model conversations in the selected company, most recently updated first.
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of conversations
|
||||
|
||||
```json
|
||||
{
|
||||
"conversations": [
|
||||
{
|
||||
"id": 1,
|
||||
"companyuser_id": 1,
|
||||
"provider": "…",
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | No company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
51
docs/api/routes/modelconversation.post.md
Normal file
51
docs/api/routes/modelconversation.post.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# `POST /modelconversation`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Create conversation
|
||||
|
||||
Creates a new conversation in state `idle`.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `provider` | yes | string | |
|
||||
| `model` | yes | string | |
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "…",
|
||||
"model": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Response `201`
|
||||
|
||||
Created conversation
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation": {
|
||||
"id": 1,
|
||||
"companyuser_id": 1,
|
||||
"provider": "…",
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid input or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
32
docs/api/routes/modelconversation/_id.delete.md
Normal file
32
docs/api/routes/modelconversation/_id.delete.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# `DELETE /modelconversation/{id}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Delete conversation
|
||||
|
||||
Deletes one of the authenticated user's model conversations and all its messages.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `204`
|
||||
|
||||
Conversation deleted
|
||||
|
||||
_No content_
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
44
docs/api/routes/modelconversation/_id.get.md
Normal file
44
docs/api/routes/modelconversation/_id.get.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# `GET /modelconversation/{id}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Get conversation
|
||||
|
||||
Gets details and current state of one of the authenticated user's model conversations.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Conversation details
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation": {
|
||||
"id": 1,
|
||||
"companyuser_id": 1,
|
||||
"provider": "…",
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
44
docs/api/routes/modelconversation/_id/message.get.md
Normal file
44
docs/api/routes/modelconversation/_id/message.get.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# `GET /modelconversation/{id}/message`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List conversation messages
|
||||
|
||||
Lists messages of one of the user's own conversations, oldest first.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of messages
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"id": 1,
|
||||
"conversation_id": 1,
|
||||
"created_at": "…",
|
||||
"role": "system",
|
||||
"message": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
65
docs/api/routes/modelconversation/_id/message.post.md
Normal file
65
docs/api/routes/modelconversation/_id/message.post.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# `POST /modelconversation/{id}/message`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Post a user message
|
||||
|
||||
Appends a message with role `user` to the conversation.
|
||||
Refused with 409 unless the conversation state is `idle`.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `message` | no | string | User message text or fallback content |
|
||||
| `role` | no | string | Message role ('user' or 'tool') |
|
||||
| `tool_call_id` | no | string | Target tool call ID when responding to a tool like ask_user |
|
||||
| `content` | no | | Tool response content payload |
|
||||
| `answer` | no | | Shorthand answer for ask_user response |
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "…",
|
||||
"role": "user",
|
||||
"tool_call_id": "…",
|
||||
"content": "…",
|
||||
"answer": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Response `201`
|
||||
|
||||
Created message
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"id": 1,
|
||||
"conversation_id": 1,
|
||||
"created_at": "…",
|
||||
"role": "system",
|
||||
"message": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID, empty message or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
| 409 | Conversation is not idle — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
37
docs/api/routes/modelconversation/_id/stream.md
Normal file
37
docs/api/routes/modelconversation/_id/stream.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# `GET /modelconversation/{id}/stream`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Stream conversation events (SSE)
|
||||
|
||||
Opens a Server-Sent Events stream for the specified conversation.
|
||||
Streams `message` events (persisted user/assistant messages), live `delta`
|
||||
text chunks of the model answer, and `state` events (`thinking`, `working`,
|
||||
`done`). The SSE states are not stored; the database only knows `idle`/`busy`.
|
||||
`done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or
|
||||
`error` (string) if the run failed.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
SSE stream (`text/event-stream`). Emits `state`, `delta`, and `message` events.
|
||||
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
62
docs/api/routes/public/_companyId/articles.md
Normal file
62
docs/api/routes/public/_companyId/articles.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# `GET /public/{companyId}/articles`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
List public articles
|
||||
|
||||
Returns a paginated list of catalog articles for the specified company, optionally filtered by category.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `companyId` | yes | integer | Company ID |
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `categoryId` | no | integer | Optional category ID to filter by |
|
||||
| `search` | no | string | Search query for name or SKU |
|
||||
| `limit` | no | integer | Number of articles to return (max 200) |
|
||||
| `offset` | no | integer | Number of articles to skip |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Articles list
|
||||
|
||||
```json
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"slug": "…",
|
||||
"net": 1,
|
||||
"taxRate": 1,
|
||||
"gross": 1,
|
||||
"trackStock": true,
|
||||
"brand": {
|
||||
"id": 1,
|
||||
"name": "…"
|
||||
},
|
||||
"fullHash": "…",
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid parameters — `{"error":"…"}` |
|
||||
| 404 | Company not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
64
docs/api/routes/public/_companyId/articles/_sku.md
Normal file
64
docs/api/routes/public/_companyId/articles/_sku.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# `GET /public/{companyId}/articles/{sku}`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
Get public article detail
|
||||
|
||||
Returns detailed public catalog data for a single article identified by SKU.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `companyId` | yes | integer | Company ID |
|
||||
| `sku` | yes | string | Article SKU |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Article detail
|
||||
|
||||
```json
|
||||
{
|
||||
"article": {
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"slug": "…",
|
||||
"description": "…",
|
||||
"unitamount": 1,
|
||||
"weight": 1,
|
||||
"trackStock": true,
|
||||
"unit": {},
|
||||
"brand": {},
|
||||
"price": {},
|
||||
"prices": [
|
||||
{}
|
||||
],
|
||||
"categories": [
|
||||
{}
|
||||
],
|
||||
"photos": [
|
||||
{}
|
||||
],
|
||||
"eans": [
|
||||
"…"
|
||||
],
|
||||
"isFather": true,
|
||||
"fatherSku": "…",
|
||||
"variants": [
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid parameters — `{"error":"…"}` |
|
||||
| 404 | Article or Company not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
46
docs/api/routes/public/_companyId/categories.md
Normal file
46
docs/api/routes/public/_companyId/categories.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# `GET /public/{companyId}/categories`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
List public categories
|
||||
|
||||
Returns the category tree for the specified company.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `companyId` | yes | integer | Company ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Category list
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"id": 1,
|
||||
"parentId": 1,
|
||||
"sort": 1,
|
||||
"name": "…",
|
||||
"slug": "…",
|
||||
"fullHash": "…",
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid parameters — `{"error":"…"}` |
|
||||
| 404 | Company not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
52
docs/api/routes/vfs/_*path.md
Normal file
52
docs/api/routes/vfs/_*path.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# `GET /vfs/{*path}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Get a virtual filesystem file
|
||||
|
||||
Returns a file the model produced in the `javascript_execute` sandbox
|
||||
(virtual filesystem), for the authenticated user's selected company.
|
||||
|
||||
Embed the returned URL in chat markdown to display a generated image:
|
||||
``. *
|
||||
Images are ALWAYS returned as a preview: scaled to fit inside a 250x250 box with the
|
||||
aspect ratio preserved (never stretched), and anything smaller than 100 px on its larger
|
||||
edge is upscaled to 100 px. The preview carries a translucent strip along the bottom
|
||||
edge listing the ORIGINAL width, height, file size, MIME type and filename. *
|
||||
Only image MIME types the sandbox produces are rendered inline; every other file
|
||||
type is sent with `Content-Disposition: attachment` so it downloads rather than
|
||||
being interpreted as a document in the app's origin.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `path` | yes | string | VFS file path (e.g. `example.png`, `labels/a.png`) |
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `download` | no | boolean | Return the original, unmodified file instead of the standard preview.
|
||||
Without this parameter images are always served as a 250x250 aspect-preserving
|
||||
preview with a metadata strip. Ignored for non-image files, which are always
|
||||
served as-is.
|
||||
|
|
||||
|
||||
## Response `200`
|
||||
|
||||
File bytes
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid path, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | File not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -28,7 +28,8 @@
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.5",
|
||||
"swagger-ui-dist": "^5.33.1"
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"vm2": "^3.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
@@ -936,6 +937,30 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -4108,6 +4133,22 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vm2": {
|
||||
"version": "3.12.2",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.12.2.tgz",
|
||||
"integrity": "sha512-LRHt4xU6eXshlZEcSZVrTehLGxRDjHvjmFPGwEp4xGpZX61QzH6Xm9wK2nU7XAp36V1w2xILAL1LwxVkgD1Ipw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-walk": "^8.3.4"
|
||||
},
|
||||
"bin": {
|
||||
"vm2": "bin/vm2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.5",
|
||||
"swagger-ui-dist": "^5.33.1"
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"vm2": "^3.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
|
||||
@@ -41,9 +41,33 @@ function isRouteOmitted(method, apiPath) {
|
||||
if (apiPath === '/posterminal' || apiPath.startsWith('/posterminal/')) {
|
||||
return true;
|
||||
}
|
||||
if (apiPath === '/modelconversation' || apiPath.startsWith('/modelconversation/')) {
|
||||
return true;
|
||||
}
|
||||
if (apiPath === '/public' || apiPath.startsWith('/public/')) {
|
||||
return true;
|
||||
}
|
||||
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes omitted from the main overview because their payload is binary or streaming, which is
|
||||
* useless to the `dashback_request` tool — but which ARE reachable with `fetch()` from inside
|
||||
* `javascript_execute`, where the bytes can be decoded, transformed, written to the VFS and shown
|
||||
* as an image. These are documented separately so the model knows about them without cluttering
|
||||
* the request-shaped route table.
|
||||
*
|
||||
* Deliberately NOT listed: import/export (administrative bulk operations the model must not
|
||||
* trigger), `/v1/*` (legacy POS compat), `/events` and `/serper/images` (long-lived SSE),
|
||||
* `/modelconversation/*` and `/posterminal/*` (UI plumbing, recursion risk), `/public/*`, and every
|
||||
* auth/company mutation.
|
||||
*/
|
||||
const JS_FETCH_ONLY_ROUTES = [
|
||||
'GET /image/{fullHash}',
|
||||
'GET /thumb/{fullHash}',
|
||||
'POST /serper/image',
|
||||
];
|
||||
|
||||
const spec = getOpenApiSpec(true);
|
||||
|
||||
function ensureDir(dir) {
|
||||
@@ -438,7 +462,7 @@ function renderReadme(operations) {
|
||||
lines.push(`| [\`routes/${op.file}\`](routes/${op.file}) | \`${op.method.toUpperCase()} ${op.path}\` |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Route overview: [`routes.md`](routes.md).');
|
||||
lines.push('Route overview: [`routes.md`](routes.md) | All routes: [`routes-all.md`](routes-all.md).');
|
||||
lines.push('');
|
||||
lines.push('## Signals (SSE)');
|
||||
lines.push('');
|
||||
@@ -648,9 +672,9 @@ function summarizeOperationReturns(operation) {
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function renderRoutesOverview(operations) {
|
||||
function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
|
||||
const lines = [];
|
||||
lines.push('# Dash API Routes Overview');
|
||||
lines.push(`# ${title}`);
|
||||
lines.push('');
|
||||
lines.push(`Overview of all ${operations.length} API endpoints across all modules.`);
|
||||
lines.push('');
|
||||
@@ -675,6 +699,56 @@ function renderRoutesOverview(operations) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra section appended to routes.md only. `all: false` renders it into the model-facing
|
||||
* overview; routes-all.md stays a complete listing with no behavioural advice.
|
||||
*/
|
||||
function renderJsFetchSection(allOperations) {
|
||||
const lines = [];
|
||||
lines.push('### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'These routes are NOT in the table above because their responses are binary or long-lived ' +
|
||||
'streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from ' +
|
||||
'inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write ' +
|
||||
'them to the VFS and embed them in your reply:',
|
||||
);
|
||||
lines.push('');
|
||||
lines.push('| Method | Route | Returns | Notes |');
|
||||
lines.push('|:---|:---|:---|:---|');
|
||||
lines.push(
|
||||
'| `GET` | `/image/{fullHash}` | `image/avif` | Full-size article/brand/category image by SHA-256 hash. ' +
|
||||
'Use for inspecting or re-encoding an existing image.',
|
||||
);
|
||||
lines.push(
|
||||
'| `GET` | `/thumb/{fullHash}` | `image/avif` | Thumb AVIF by SHA-256 hash — cheaper than the full image. ' +
|
||||
'Takes the same `fullHash` as `/image/{fullHash}`.',
|
||||
);
|
||||
lines.push(
|
||||
'| `POST` | `/serper/image` | `image/avif` | Crawl an image from a URL, auto-crop transparent/white borders. ' +
|
||||
'Body: `{ url }`.',
|
||||
);
|
||||
lines.push('');
|
||||
lines.push('Save the bytes to the VFS, then embed them in your reply as ``:');
|
||||
lines.push('');
|
||||
lines.push('```js');
|
||||
lines.push('module.exports = (async () => {');
|
||||
lines.push(' const hash = "…64-char sha-256…";');
|
||||
lines.push(' const res = await fetch(`/thumb/${hash}`); // authenticated automatically');
|
||||
lines.push(' const buf = Buffer.from(await res.arrayBuffer());');
|
||||
lines.push(' fs.writeFileSync("artifacts/thumb.avif", buf);');
|
||||
lines.push(' return { bytes: buf.length };');
|
||||
lines.push('})();');
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'`fullHash` appears on articles, brands and categories. `thumbHash` is a base64 blur-up placeholder, ' +
|
||||
'not a content hash, and is not accepted here.',
|
||||
);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- generate ---
|
||||
ensureDir(docsDir);
|
||||
rmDirContents(routesDocsDir);
|
||||
@@ -706,8 +780,12 @@ for (const apiPath of paths) {
|
||||
}
|
||||
|
||||
const overviewOperations = operations.filter((op) => !op.omittedFromOverview);
|
||||
fs.writeFileSync(path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations));
|
||||
fs.writeFileSync(
|
||||
path.join(docsDir, 'routes.md'),
|
||||
renderRoutesOverview(overviewOperations) + renderJsFetchSection(operations)
|
||||
);
|
||||
fs.writeFileSync(path.join(docsDir, 'routes-all.md'), renderRoutesOverview(operations, 'Dash API Routes Overview (All)'));
|
||||
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
|
||||
fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2));
|
||||
|
||||
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + openapi.json → docs/api/`);
|
||||
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + ${operations.length} in routes-all.md + openapi.json → docs/api/`);
|
||||
|
||||
@@ -11,6 +11,7 @@ import env from '../config/env.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { ensureConnection } from '../db/store.js';
|
||||
import { closeAll as closeAllSse } from './sse.js';
|
||||
import { closeAllManagers } from '../services/conversationManager.js';
|
||||
import { getOrCreateCert } from '../util/cert.js';
|
||||
import v1Router from '../v1/index.js';
|
||||
|
||||
@@ -47,6 +48,16 @@ let startedAt;
|
||||
function corsMiddleware(req, res, next) {
|
||||
const origin = req.headers.origin;
|
||||
|
||||
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
if (origin && env.corsOrigins.includes(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
@@ -66,8 +77,8 @@ async function createApi() {
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
// Do not attach no-store to SSE endpoint
|
||||
if (req.path !== '/api/events') {
|
||||
// Do not attach no-store to SSE or public endpoints
|
||||
if (req.path !== '/api/events' && !req.path.startsWith('/api/public') && !req.path.startsWith('/public')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
next();
|
||||
@@ -89,14 +100,20 @@ async function createApi() {
|
||||
app.use('/v1', v1Router);
|
||||
app.use('/api/v1', v1Router);
|
||||
|
||||
app.use((err, _req, res, next) => {
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err.type === 'entity.too.large') {
|
||||
return res.status(413).json({ error: 'Payload too large' });
|
||||
}
|
||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||
return res.status(400).json({ error: 'Invalid JSON body' });
|
||||
}
|
||||
next(err);
|
||||
console.error('Unhandled API error:', err);
|
||||
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
||||
res.status(status).json({ error: err.message || 'Internal server error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
@@ -174,6 +191,7 @@ export async function stopApi() {
|
||||
// Long-lived SSE (and keep-alive) sockets otherwise block server.close()
|
||||
// forever — node --watch hangs on "Waiting for graceful termination...".
|
||||
closeAllSse();
|
||||
closeAllManagers();
|
||||
const closers = [];
|
||||
if (server) {
|
||||
const s = server;
|
||||
|
||||
@@ -55,6 +55,10 @@ export const authMiddleware = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/@') || req.path.startsWith('/node_modules') || req.path.startsWith('/src') || req.path === '/favicon.ico') {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -852,6 +852,66 @@
|
||||
* transaction_text:
|
||||
* type: string
|
||||
* description: Transaction memo / details
|
||||
* ModelConversation:
|
||||
* type: object
|
||||
* required: [id, companyuser_id, provider, model, created_at, updated_at, state]
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* companyuser_id:
|
||||
* type: integer
|
||||
* provider:
|
||||
* type: string
|
||||
* model:
|
||||
* type: string
|
||||
* created_at:
|
||||
* type: string
|
||||
* updated_at:
|
||||
* type: string
|
||||
* state:
|
||||
* type: string
|
||||
* enum: [idle, busy]
|
||||
* description: Messages can only be posted while `idle`
|
||||
* ModelConversationCreateInput:
|
||||
* type: object
|
||||
* required: [provider, model]
|
||||
* properties:
|
||||
* provider:
|
||||
* type: string
|
||||
* model:
|
||||
* type: string
|
||||
* ModelMessage:
|
||||
* type: object
|
||||
* required: [id, conversation_id, created_at, role, message]
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* conversation_id:
|
||||
* type: integer
|
||||
* created_at:
|
||||
* type: string
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [system, user, assistant, tool, reasoning]
|
||||
* message:
|
||||
* type: string
|
||||
* ModelMessageCreateInput:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* description: User message text or fallback content
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [user, tool]
|
||||
* description: Message role ('user' or 'tool')
|
||||
* tool_call_id:
|
||||
* type: string
|
||||
* description: Target tool call ID when responding to a tool like ask_user
|
||||
* content:
|
||||
* description: Tool response content payload
|
||||
* answer:
|
||||
* description: Shorthand answer for ask_user response
|
||||
* Printer:
|
||||
* type: object
|
||||
* required: [id, company_id, address, type, sort]
|
||||
|
||||
@@ -45,6 +45,7 @@ export const openApiOptions = {
|
||||
{ name: 'Stock', description: 'Manual stock additions and removals' },
|
||||
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
|
||||
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
|
||||
{ name: 'PublicCatalog', description: 'Public product catalog API' },
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
|
||||
@@ -70,7 +70,7 @@ function articleErrorStatus(error) {
|
||||
if (error.message === 'Unit not found') return 404;
|
||||
if (error.message === 'Invalid eans') return 400;
|
||||
if (error.message === 'EAN already in use' || error.message === 'EAN already assigned') return 409;
|
||||
if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint')) return 409;
|
||||
if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint') || error.message?.includes('already exists')) return 409;
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Brand not found') return 404;
|
||||
if (error.message === 'Invalid brandId') return 400;
|
||||
@@ -222,6 +222,7 @@ export function register(app, basePath = '') {
|
||||
return res.status(400).json({ error: 'Invalid track_stock' });
|
||||
}
|
||||
const track_stock = rawTrack !== undefined ? Boolean(rawTrack) : true;
|
||||
const customSku = typeof req.body?.sku === 'string' && req.body.sku.trim() ? req.body.sku.trim() : undefined;
|
||||
|
||||
try {
|
||||
const created = await db.createUnderParent({
|
||||
@@ -229,6 +230,7 @@ export function register(app, basePath = '') {
|
||||
parentId,
|
||||
name,
|
||||
track_stock,
|
||||
sku: customSku,
|
||||
});
|
||||
emitArticlesSignal(req);
|
||||
const article = {
|
||||
@@ -1540,8 +1542,25 @@ export function register(app, basePath = '') {
|
||||
* Returns every article reachable via the company's category tree.
|
||||
* Designed for client-side search / autocomplete preload.
|
||||
* Auth via company access.
|
||||
*
|
||||
* REQUIRES `windowSize`. Without it the response is always an empty list — the full
|
||||
* index can be very large and is not useful to a caller that did not ask for a
|
||||
* specific window. Pass the number of entries the client actually intends to preload.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: windowSize
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 1000
|
||||
* description: |
|
||||
* Number of articles to return (window size). Required; omitting it returns an
|
||||
* empty `articles` array. `truncated` is true when more entries exist than were
|
||||
* returned, signalling the client should request a larger window or narrow its
|
||||
* query.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Search index
|
||||
@@ -1570,6 +1589,9 @@ export function register(app, basePath = '') {
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* truncated:
|
||||
* type: boolean
|
||||
* description: True when the window was smaller than the total number of matching articles.
|
||||
* 400:
|
||||
* description: No company selected
|
||||
* content:
|
||||
@@ -1599,11 +1621,28 @@ export function register(app, basePath = '') {
|
||||
const companyId = req.user.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return res.status(400).json({ error: 'No company selected' });
|
||||
}
|
||||
|
||||
// A window size must be given explicitly. Without one the index can be arbitrarily large,
|
||||
// and returning the entire result set to a caller that never asked for a window is
|
||||
// exactly the "fetch everything" behaviour this guard exists to prevent.
|
||||
const rawWindowSize = req.query.windowSize;
|
||||
if (rawWindowSize === undefined || rawWindowSize === '') {
|
||||
return res.status(200).json({ articles: [], truncated: false });
|
||||
}
|
||||
|
||||
const windowSize = parseInt(rawWindowSize, 10);
|
||||
if (!Number.isInteger(windowSize) || windowSize < 1 || windowSize > 1000) {
|
||||
return res.status(400).json({ error: 'windowSize must be an integer between 1 and 1000' });
|
||||
}
|
||||
|
||||
try {
|
||||
const articles = await db.listSearchIndex(req.user.id);
|
||||
res.status(200).json({ articles });
|
||||
const all = await db.listSearchIndex(req.user.id);
|
||||
// `length === windowSize` is the same truncation signal used by
|
||||
// GET /article/{sku}/history: the window is exactly full, so more may exist.
|
||||
const truncated = all.length > windowSize;
|
||||
const articles = truncated ? all.slice(0, windowSize) : all;
|
||||
res.status(200).json({ articles, truncated });
|
||||
} catch (error) {
|
||||
const status = articleErrorStatus(error);
|
||||
if (status === 500) console.error('List article search index failed:', error);
|
||||
|
||||
@@ -46,7 +46,7 @@ export function register(app, basePath = '') {
|
||||
* get:
|
||||
* tags: [LabelTemplate]
|
||||
* summary: List label templates
|
||||
* description: Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. `content.objects` is omitted to reduce payload size.
|
||||
* description: Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. The `content` field is omitted to reduce payload size (use GET /labeltemplate/{id} for it).
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-markdown: labeltemplate/list.md
|
||||
@@ -83,7 +83,10 @@ export function register(app, basePath = '') {
|
||||
|
||||
try {
|
||||
const templates = await db.list(req.user.id);
|
||||
res.status(200).json({ labeltemplates: templates.map(serializeTemplate) });
|
||||
res.status(200).json({ labeltemplates: templates.map((t) => {
|
||||
const { content, ...rest } = serializeTemplate(t);
|
||||
return rest;
|
||||
}) });
|
||||
} catch (error) {
|
||||
const status = labeltemplateErrorStatus(error);
|
||||
if (status === 500) console.error('List label templates failed:', error);
|
||||
|
||||
577
src/api/routes/modelconversation.js
Normal file
577
src/api/routes/modelconversation.js
Normal file
@@ -0,0 +1,577 @@
|
||||
import * as db from '../../db/modelconversation.js';
|
||||
import { emit } from '../sse.js';
|
||||
import { getOrCreateManager, removeManager } from '../../services/conversationManager.js';
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return { error: 'No company selected' };
|
||||
}
|
||||
return { companyId };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, field) {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { error: `${field} is required` };
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
function modelConversationErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Conversation not found') return 404;
|
||||
if (error.message === 'Provider required') return 400;
|
||||
if (error.message === 'Model required') return 400;
|
||||
if (error.message === 'Message required') return 400;
|
||||
if (error.message === 'Conversation is not idle') return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers model conversation routes. Conversations are private to the
|
||||
* authenticated user within their selected company.
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: List conversations
|
||||
* description: Lists the authenticated user's model conversations in the selected company, most recently updated first.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of conversations
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* conversations:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/ModelConversation'
|
||||
* 400:
|
||||
* description: No company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const conversations = await db.list(req.user.id);
|
||||
res.status(200).json({ conversations });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('List model conversations failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation:
|
||||
* post:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Create conversation
|
||||
* description: Creates a new conversation in state `idle`.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ModelConversationCreateInput'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created conversation
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* conversation:
|
||||
* $ref: '#/components/schemas/ModelConversation'
|
||||
* 400:
|
||||
* description: Invalid input or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.post(`${basePath}/modelconversation`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const { provider, model } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const conversation = await db.create(req.user.id, { provider, model });
|
||||
emit('modelconversation', { companyId: selected.companyId });
|
||||
res.status(201).json({ conversation });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Create model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Get conversation
|
||||
* description: Gets details and current state of one of the authenticated user's model conversations.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Conversation details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* conversation:
|
||||
* $ref: '#/components/schemas/ModelConversation'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const conversation = await db.get(req.user.id, parsedId.id);
|
||||
res.status(200).json({ conversation });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Get model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}:
|
||||
* delete:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Delete conversation
|
||||
* description: Deletes one of the authenticated user's model conversations and all its messages.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Conversation deleted
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.delete(`${basePath}/modelconversation/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.remove(req.user.id, parsedId.id);
|
||||
removeManager(parsedId.id);
|
||||
emit('modelconversation', { companyId: selected.companyId });
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Delete model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}/stream:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Stream conversation events (SSE)
|
||||
* description: |
|
||||
* Opens a Server-Sent Events stream for the specified conversation.
|
||||
* Streams `message` events (persisted user/assistant messages), live `delta`
|
||||
* text chunks of the model answer, and `state` events (`thinking`, `working`,
|
||||
* `done`). The SSE states are not stored; the database only knows `idle`/`busy`.
|
||||
* `done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or
|
||||
* `error` (string) if the run failed.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: |
|
||||
* SSE stream (`text/event-stream`). Emits `state`, `delta`, and `message` events.
|
||||
* content:
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* example: |
|
||||
* event: state
|
||||
* data: {"state":"thinking"}
|
||||
*
|
||||
* event: state
|
||||
* data: {"state":"working"}
|
||||
*
|
||||
* event: delta
|
||||
* data: {"delta":"Hel","text":"Hel"}
|
||||
*
|
||||
* event: message
|
||||
* data: {"id":2,"conversation_id":1,"created_at":"...","role":"assistant","message":"Hello there"}
|
||||
*
|
||||
* event: state
|
||||
* data: {"state":"done","usage":{"input":20,"cached":0,"output":3,"cost":0.0001}}
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation/:id/stream`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure conversation exists and user has access
|
||||
await db.get(req.user.id, parsedId.id);
|
||||
const manager = getOrCreateManager(parsedId.id, {
|
||||
companyId: selected.companyId,
|
||||
googleAuthId: req.user.id,
|
||||
});
|
||||
manager.subscribe(res, req);
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Stream model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}/message:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: List conversation messages
|
||||
* description: Lists messages of one of the user's own conversations, oldest first.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of messages
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/ModelMessage'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation/:id/message`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await db.listMessages(req.user.id, parsedId.id);
|
||||
res.status(200).json({ messages });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('List model messages failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}/message:
|
||||
* post:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Post a user message
|
||||
* description: |
|
||||
* Appends a message with role `user` to the conversation.
|
||||
* Refused with 409 unless the conversation state is `idle`.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ModelMessageCreateInput'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created message
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* $ref: '#/components/schemas/ModelMessage'
|
||||
* 400:
|
||||
* description: Invalid ID, empty message or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 409:
|
||||
* description: Conversation is not idle
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.post(`${basePath}/modelconversation/:id/message`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
const { message, tool_call_id, role, content, answer } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const manager = getOrCreateManager(parsedId.id, {
|
||||
companyId: selected.companyId,
|
||||
googleAuthId: req.user.id,
|
||||
});
|
||||
|
||||
let created;
|
||||
if (role === 'tool' || tool_call_id) {
|
||||
if (!tool_call_id) {
|
||||
return res.status(400).json({ error: 'tool_call_id is required for tool response' });
|
||||
}
|
||||
created = await manager.handleToolMessage(req.user.id, {
|
||||
tool_call_id,
|
||||
content,
|
||||
answer: answer !== undefined ? answer : message,
|
||||
});
|
||||
} else {
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: 'Message required' });
|
||||
}
|
||||
created = await manager.handleUserMessage(req.user.id, { message });
|
||||
}
|
||||
|
||||
emit(`modelconversation-${parsedId.id}`, { companyId: selected.companyId });
|
||||
res.status(201).json({ message: created });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Post model message failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
364
src/api/routes/publicCatalog.js
Normal file
364
src/api/routes/publicCatalog.js
Normal file
@@ -0,0 +1,364 @@
|
||||
import * as db from '../../db/publicCatalog.js';
|
||||
|
||||
function parseCompanyId(id) {
|
||||
const companyId = Number(id);
|
||||
if (!Number.isInteger(companyId) || companyId <= 0) {
|
||||
return { error: 'Invalid company id' };
|
||||
}
|
||||
return { companyId };
|
||||
}
|
||||
|
||||
function parseSku(value) {
|
||||
const sku = typeof value === 'string' ? value.trim() : '';
|
||||
if (!sku) return { error: 'sku is required' };
|
||||
return { sku: decodeURIComponent(sku) };
|
||||
}
|
||||
|
||||
function publicErrorStatus(error) {
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Article not found' || error.message?.startsWith('Article not found')) return 404;
|
||||
if (error.message === 'Category not found') return 404;
|
||||
if (error.message?.includes('required') || error.message?.includes('Invalid')) return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers public catalog routes (no session/cookie auth required).
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /public/{companyId}/categories:
|
||||
* get:
|
||||
* tags: [PublicCatalog]
|
||||
* summary: List public categories
|
||||
* description: Returns the category tree for the specified company.
|
||||
* security: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: companyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Company ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Category list
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* categories:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* parentId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* sort:
|
||||
* type: integer
|
||||
* name:
|
||||
* type: string
|
||||
* slug:
|
||||
* type: string
|
||||
* fullHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* thumbW:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbH:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Company not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/public/:companyId/categories`, async (req, res) => {
|
||||
const parsed = parseCompanyId(req.params.companyId);
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const categories = await db.listCategories(parsed.companyId);
|
||||
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300');
|
||||
res.status(200).json({ categories });
|
||||
} catch (error) {
|
||||
const status = publicErrorStatus(error);
|
||||
if (status === 500) console.error('Public listCategories failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /public/{companyId}/articles:
|
||||
* get:
|
||||
* tags: [PublicCatalog]
|
||||
* summary: List public articles
|
||||
* description: Returns a paginated list of catalog articles for the specified company, optionally filtered by category.
|
||||
* security: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: companyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Company ID
|
||||
* - in: query
|
||||
* name: categoryId
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Optional category ID to filter by
|
||||
* - in: query
|
||||
* name: search
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Search query for name or SKU
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* description: Number of articles to return (max 200)
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* description: Number of articles to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Articles list
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* articles:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* sku:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* slug:
|
||||
* type: string
|
||||
* net:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* taxRate:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* gross:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* trackStock:
|
||||
* type: boolean
|
||||
* brand:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* name:
|
||||
* type: string
|
||||
* fullHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* thumbW:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbH:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Company not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/public/:companyId/articles`, async (req, res) => {
|
||||
const parsed = parseCompanyId(req.params.companyId);
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
const categoryId = req.query.categoryId != null ? Number(req.query.categoryId) : undefined;
|
||||
if (categoryId !== undefined && (!Number.isInteger(categoryId) || categoryId <= 0)) {
|
||||
return res.status(400).json({ error: 'Invalid categoryId' });
|
||||
}
|
||||
|
||||
const limit = req.query.limit != null ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset != null ? Number(req.query.offset) : undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
|
||||
|
||||
try {
|
||||
const articles = await db.listArticles(parsed.companyId, {
|
||||
categoryId,
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300');
|
||||
res.status(200).json({ articles });
|
||||
} catch (error) {
|
||||
const status = publicErrorStatus(error);
|
||||
if (status === 500) console.error('Public listArticles failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /public/{companyId}/articles/{sku}:
|
||||
* get:
|
||||
* tags: [PublicCatalog]
|
||||
* summary: Get public article detail
|
||||
* description: Returns detailed public catalog data for a single article identified by SKU.
|
||||
* security: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: companyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Company ID
|
||||
* - in: path
|
||||
* name: sku
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Article SKU
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Article detail
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* article:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* sku:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* slug:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* unitamount:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* weight:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* trackStock:
|
||||
* type: boolean
|
||||
* unit:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* brand:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* price:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* prices:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* categories:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* photos:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* eans:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* isFather:
|
||||
* type: boolean
|
||||
* fatherSku:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* variants:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Article or Company not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/public/:companyId/articles/:sku`, async (req, res) => {
|
||||
const parsedCompany = parseCompanyId(req.params.companyId);
|
||||
if (parsedCompany.error) {
|
||||
return res.status(400).json({ error: parsedCompany.error });
|
||||
}
|
||||
|
||||
const parsedSku = parseSku(req.params.sku);
|
||||
if (parsedSku.error) {
|
||||
return res.status(400).json({ error: parsedSku.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const article = await db.getArticleBySku(parsedCompany.companyId, parsedSku.sku);
|
||||
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300');
|
||||
res.status(200).json({ article });
|
||||
} catch (error) {
|
||||
const status = publicErrorStatus(error);
|
||||
if (status === 500) console.error('Public getArticleBySku failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
503
src/api/routes/vfs.js
Normal file
503
src/api/routes/vfs.js
Normal file
@@ -0,0 +1,503 @@
|
||||
import * as db from '../../db/modelvfs.js';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never
|
||||
// stretched, so a 2000x100 image becomes 250x13 and not 250x250.
|
||||
const THUMB_BOX = 250;
|
||||
// Images whose larger edge is below this are UPSCALED to it, so tiny icons stay legible.
|
||||
const THUMB_MIN_EDGE = 100;
|
||||
// Output format for generated previews. WebP keeps the metadata strip visually lossless at
|
||||
// small sizes without ballooning bytes the way PNG text overlays would.
|
||||
const THUMB_FORMAT = 'webp';
|
||||
const THUMB_QUALITY = 88;
|
||||
|
||||
// Monospace font available on the host (verified: DejaVu Sans Mono ships with the image).
|
||||
const THUMB_FONT = 'DejaVu Sans Mono';
|
||||
const THUMB_FONT_FALLBACK = 'sans-serif';
|
||||
|
||||
// Dark translucent band behind the metadata row so light text stays readable on any image.
|
||||
const THUMB_BG = '#101317';
|
||||
const THUMB_TEXT = '#e8eef5';
|
||||
const THUMB_MUTED = '#a9bccf';
|
||||
|
||||
// Advance width of a monospace glyph as a fraction of font size. DejaVu Sans Mono is 0.6023em;
|
||||
// rounded up so width estimates never underestimate the real glyph advance.
|
||||
const MONO_ADVANCE = 0.61;
|
||||
|
||||
// Legibility limits for the overlay text. The floor matters most: it is what makes metadata
|
||||
// readable on a 100px thumbnail instead of a smear of 8px glyphs.
|
||||
const FONT_MIN_PX = 10;
|
||||
const FONT_MAX_PX = 13;
|
||||
|
||||
// Fraction of the thumbnail's height the metadata band may occupy.
|
||||
const BAND_HEIGHT_RATIO = 0.5;
|
||||
|
||||
// Files the sandbox produces that are safe to render inline in a browser document.
|
||||
// Anything not listed here (`.html`, `.svg`, `.js`, …) is served as a download so a stored
|
||||
// artifact can never be loaded as an executable document in the app's origin.
|
||||
const INLINE_RENDERABLE_MIME = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
]);
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return { error: 'No company selected' };
|
||||
}
|
||||
return { companyId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a VFS file reference and decide whether it may be rendered inline.
|
||||
*
|
||||
* Accepts either the plain VFS name (`example.png`, `labels/a.png`) or a URL pointing at this
|
||||
* endpoint (`/api/vfs/example.png`), so a model can hand back the exact URL it was given.
|
||||
* Returns `isInline: false` for anything that is not a known image type — the caller then
|
||||
* sends `Content-Disposition: attachment` so it downloads instead of rendering.
|
||||
*
|
||||
* @param {string} raw
|
||||
* @returns {{ name: string } | { error: string }}
|
||||
*/
|
||||
function parseVfsRef(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return { error: 'path is required' };
|
||||
}
|
||||
|
||||
let name = raw.trim();
|
||||
|
||||
// Accept absolute URLs to this endpoint: strip origin and the /api/vfs prefix.
|
||||
if (/^https?:\/\//i.test(name)) {
|
||||
try {
|
||||
name = new URL(name).pathname;
|
||||
} catch {
|
||||
return { error: 'path is not a valid URL' };
|
||||
}
|
||||
}
|
||||
name = name.replace(/^\/+/, '');
|
||||
// Strip a leading `api/` so both `/vfs/x.png` and `/api/vfs/x.png` resolve the same way.
|
||||
name = name.replace(/^api\//i, '').replace(/^vfs\//i, '');
|
||||
|
||||
// Reject traversal outright. Never let a caller escape the VFS root via `..` segments,
|
||||
// percent-encoding, or backslashes — this value keys a database lookup, not a disk read,
|
||||
// but normalising here keeps stored names and requested names identical.
|
||||
let decoded = name;
|
||||
try {
|
||||
decoded = decodeURIComponent(name);
|
||||
} catch {
|
||||
return { error: 'path is not valid URL encoding' };
|
||||
}
|
||||
if (decoded.includes('\0') || decoded.includes('\\')) {
|
||||
return { error: 'path contains invalid characters' };
|
||||
}
|
||||
const segments = decoded.split('/').filter((s) => s && s !== '.');
|
||||
if (segments.some((s) => s === '..')) {
|
||||
return { error: 'path escapes virtual filesystem boundary' };
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
return { error: 'path is required' };
|
||||
}
|
||||
|
||||
|
||||
const normalized = segments.join('/');
|
||||
const ext = normalized.slice(normalized.lastIndexOf('.')).toLowerCase();
|
||||
const isInline = INLINE_RENDERABLE_MIME.has(MIME_BY_EXTENSION[ext] || '');
|
||||
return { name: normalized, isInline };
|
||||
}
|
||||
|
||||
// Only the extensions the sandbox actually writes (see getMimeType in tools/vfs.js).
|
||||
const MIME_BY_EXTENSION = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
function vfsFileErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
if (error.message === 'File not found') return 404;
|
||||
if (error.message === 'File name is required') return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable byte size, e.g. `1.4 MB`.
|
||||
* @param {number} bytes
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return 'unknown size';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let i = 0;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** XML-escape for text embedded in the SVG overlay. */
|
||||
function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a monospace string so it fits within `maxPx`, appending an ellipsis when cut.
|
||||
*
|
||||
* SVG `<text>` cannot be measured server-side, so widths are estimated from the font's
|
||||
* monospace advance ratio (slightly conservative, so we never under-estimate).
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} maxPx
|
||||
* @param {number} fontSize
|
||||
* @returns {string}
|
||||
*/
|
||||
function fitToWidth(text, maxPx, fontSize) {
|
||||
const value = String(text);
|
||||
const perChar = fontSize * MONO_ADVANCE;
|
||||
if (perChar <= 0) return value;
|
||||
if (value.length * perChar <= maxPx) return value;
|
||||
const room = Math.max(1, Math.floor(maxPx / perChar) - 1);
|
||||
return `${value.slice(0, room)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SVG metadata overlay: a translucent "pill" strip along the bottom edge carrying the
|
||||
* original width, height, file size, MIME type and filename.
|
||||
*
|
||||
* This describes the ORIGINAL file, not the thumbnail — that is the whole point of the strip.
|
||||
*
|
||||
* @param {{ width: number|null, height: number|null, size: number, mime: string, filename: string }} meta
|
||||
* @param {number} outWidth rendered overlay width (image width after scaling)
|
||||
* @param {number} outHeight rendered overlay height (image height after scaling)
|
||||
* @returns {Buffer} SVG bytes
|
||||
*/
|
||||
function buildMetaOverlay({ width, height, size, mime, filename }, outWidth, outHeight) {
|
||||
const dims = width && height ? `${width}×${height} px` : 'unknown dimensions';
|
||||
const fields = [dims, formatBytes(size), mime || 'unknown type'];
|
||||
const SEP = ' · ';
|
||||
|
||||
|
||||
// The band is drawn INSIDE the image, so it must fit within its height. Rather than
|
||||
// shrinking the font until the metadata is unreadable (the earlier approach, which produced
|
||||
// 5px text on a 100px-wide thumbnail), the text WRAPS onto as many rows as the image can
|
||||
// carry, keeping the font at a legible size.
|
||||
const maxBand = Math.max(0, Math.floor(outHeight * BAND_HEIGHT_RATIO));
|
||||
const padX0 = Math.max(3, Math.round(FONT_MIN_PX * 0.55));
|
||||
const usable0 = Math.max(1, outWidth - padX0 * 2);
|
||||
const charsPerLine = Math.max(6, Math.floor(usable0 / (FONT_MIN_PX * MONO_ADVANCE)));
|
||||
|
||||
// Wrap the joined field list into at most `maxRows` lines.
|
||||
const maxRows = Math.max(1, Math.floor((maxBand - FONT_MIN_PX * 0.7) / (FONT_MIN_PX * 1.25)) - 1);
|
||||
const full = fields.join(SEP);
|
||||
let lines = [];
|
||||
if (full.length <= charsPerLine) {
|
||||
lines = [full];
|
||||
} else {
|
||||
lines = [full];
|
||||
while (lines.length < maxRows && lines[lines.length - 1].length > charsPerLine) {
|
||||
const last = lines.pop();
|
||||
lines.push(last.slice(0, charsPerLine - 1) + '…');
|
||||
}
|
||||
// If it still does not fit, break it across the available rows instead of truncating.
|
||||
if (lines.length === 1 && full.length > charsPerLine) {
|
||||
lines = [];
|
||||
for (let i = 0; i < full.length && lines.length < maxRows; i += charsPerLine) {
|
||||
lines.push(full.slice(i, i + charsPerLine));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fontSize = FONT_MIN_PX;
|
||||
const padX = padX0;
|
||||
const lineHeight = Math.round(fontSize * 1.25);
|
||||
const barHeight = lineHeight * (lines.length + 1) + Math.round(fontSize * 0.7);
|
||||
if (barHeight > outHeight) return null;
|
||||
const y = outHeight - barHeight;
|
||||
|
||||
|
||||
|
||||
const nameText = fitToWidth(String(filename), outWidth - padX * 2, fontSize);
|
||||
const lineTexts = lines
|
||||
.map(
|
||||
(line, i) =>
|
||||
`<text x="${padX}" y="${y + lineHeight * (i + 2) - Math.round(fontSize * 0.3)}" ` +
|
||||
`font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" font-size="${fontSize}" ` +
|
||||
`fill="${THUMB_MUTED}">${escapeXml(line)}</text>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${outWidth}" height="${outHeight}">` +
|
||||
`<rect x="0" y="${y}" width="${outWidth}" height="${barHeight}" fill="${THUMB_BG}" fill-opacity="0.88"/>` +
|
||||
`<text x="${padX}" y="${y + lineHeight - Math.round(fontSize * 0.35)}" font-family="${THUMB_FONT}, ${THUMB_FONT_FALLBACK}" ` +
|
||||
`font-size="${fontSize}" fill="${THUMB_TEXT}">${escapeXml(nameText)}</text>` +
|
||||
lineTexts +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a thumbnail of an image buffer.
|
||||
*
|
||||
* Sizing rule: fit inside a 250x250 box with the aspect ratio preserved (`fit: 'inside'`,
|
||||
* `withoutEnlargement: false` so small images are upscaled), then explicitly upscale anything
|
||||
* whose larger edge is still under 100 px. The metadata overlay reports the ORIGINAL dimensions.
|
||||
*
|
||||
* @param {Buffer} buffer original image bytes
|
||||
* @param {{ mime: string, filename: string }} info
|
||||
* @returns {Promise<{ buffer: Buffer, contentType: string } | null>} null when not rasterisable
|
||||
*/
|
||||
async function renderThumbnail(buffer, { mime, filename }) {
|
||||
const pipeline = sharp(buffer, { animated: false, failOn: 'none' });
|
||||
let meta;
|
||||
try {
|
||||
meta = await pipeline.metadata();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!meta?.width || !meta?.height) return null;
|
||||
|
||||
let scaled = await pipeline
|
||||
.resize({
|
||||
width: THUMB_BOX,
|
||||
height: THUMB_BOX,
|
||||
fit: 'inside',
|
||||
// Downscale only. Upscaling is handled explicitly below, so a 60x40 image ends up at
|
||||
// 100px (the THUMB_MIN_EDGE rule) rather than being blown up to fill the 250 box.
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
let scaledMeta = await sharp(scaled).metadata();
|
||||
|
||||
// Upscale small images so they do not render as an unreadable speck.
|
||||
const largerEdge = Math.max(scaledMeta.width, scaledMeta.height);
|
||||
if (largerEdge < THUMB_MIN_EDGE) {
|
||||
const factor = THUMB_MIN_EDGE / largerEdge;
|
||||
scaled = await sharp(scaled)
|
||||
.resize({
|
||||
width: Math.max(1, Math.round(scaledMeta.width * factor)),
|
||||
height: Math.max(1, Math.round(scaledMeta.height * factor)),
|
||||
fit: 'fill',
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
scaledMeta = await sharp(scaled).metadata();
|
||||
}
|
||||
|
||||
const outWidth = scaledMeta.width;
|
||||
const outHeight = scaledMeta.height;
|
||||
|
||||
const overlay = buildMetaOverlay(
|
||||
{
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
size: buffer.length,
|
||||
mime,
|
||||
filename,
|
||||
},
|
||||
outWidth,
|
||||
outHeight
|
||||
);
|
||||
|
||||
// `null` means the thumbnail is too small to carry a legible metadata band (e.g. an extreme
|
||||
// aspect ratio collapsing to a few pixels tall). Serve the plain scaled image instead.
|
||||
if (!overlay) {
|
||||
return {
|
||||
buffer: await sharp(scaled)[THUMB_FORMAT]({ quality: THUMB_QUALITY }).toBuffer(),
|
||||
contentType: `image/${THUMB_FORMAT}`,
|
||||
};
|
||||
}
|
||||
const composite = await sharp(scaled)
|
||||
.composite([{ input: overlay, top: 0, left: 0 }])
|
||||
[THUMB_FORMAT]({ quality: THUMB_QUALITY })
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: composite,
|
||||
contentType: `image/${THUMB_FORMAT}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers virtual filesystem file routes (requires auth).
|
||||
*
|
||||
* Serves artifacts the model produced in the `javascript_execute` sandbox so they can be
|
||||
* embedded in chat markdown, e.g. ``.
|
||||
*
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/{*path}:
|
||||
* get:
|
||||
* tags: [Vfs]
|
||||
* summary: Get a virtual filesystem file
|
||||
* description: |
|
||||
* Returns a file the model produced in the `javascript_execute` sandbox
|
||||
* (virtual filesystem), for the authenticated user's selected company.
|
||||
*
|
||||
* Embed the returned URL in chat markdown to display a generated image:
|
||||
* ``. *
|
||||
* Images are ALWAYS returned as a preview: scaled to fit inside a 250x250 box with the
|
||||
* aspect ratio preserved (never stretched), and anything smaller than 100 px on its larger
|
||||
* edge is upscaled to 100 px. The preview carries a translucent strip along the bottom
|
||||
* edge listing the ORIGINAL width, height, file size, MIME type and filename. *
|
||||
* Only image MIME types the sandbox produces are rendered inline; every other file
|
||||
* type is sent with `Content-Disposition: attachment` so it downloads rather than
|
||||
* being interpreted as a document in the app's origin.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: VFS file path (e.g. `example.png`, `labels/a.png`)
|
||||
* - in: query
|
||||
* name: download
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: |
|
||||
* Return the original, unmodified file instead of the standard preview.
|
||||
* Without this parameter images are always served as a 250x250 aspect-preserving
|
||||
* preview with a metadata strip. Ignored for non-image files, which are always
|
||||
* served as-is.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File bytes
|
||||
* headers:
|
||||
* Content-Disposition:
|
||||
* schema:
|
||||
* type: string
|
||||
* Cache-Control:
|
||||
* schema:
|
||||
* type: string
|
||||
* content:
|
||||
* application/octet-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* 400:
|
||||
* description: Invalid path, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: File not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/vfs/*path`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const raw = Array.isArray(req.params.path) ? req.params.path.join('/') : req.params.path;
|
||||
const parsed = parseVfsRef(raw);
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await db.read(req.user.id, parsed.name);
|
||||
if (!file?.content?.length) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
const wantsDownload = req.query.download === '1' || req.query.download === 'true';
|
||||
const isInline = parsed.isInline && !wantsDownload;
|
||||
const fallbackName = parsed.name.split('/').pop() || 'download';
|
||||
|
||||
// Images are ALWAYS served as a 250x250 aspect-preserving preview unless the caller
|
||||
// explicitly asks for the original with `download=1`. The preview carries the original
|
||||
// width/height/size/mime/filename in a pill strip, so no metadata is lost.
|
||||
if (isInline) {
|
||||
const rendered = await renderThumbnail(file.content, {
|
||||
mime: file.mime,
|
||||
filename: fallbackName,
|
||||
}).catch((err) => {
|
||||
console.error('vfs thumbnail render failed:', err.message);
|
||||
return null;
|
||||
});
|
||||
if (rendered) {
|
||||
res.setHeader('Content-Type', rendered.contentType);
|
||||
res.setHeader('Content-Length', String(rendered.buffer.length));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
|
||||
// Previews are derived data — never cache them immutably, the source file can change.
|
||||
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`);
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
return res.status(200).send(rendered.buffer);
|
||||
}
|
||||
// Rendering failed (unsupported/corrupt image) — fall through and serve the original.
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', file.mime || 'application/octet-stream');
|
||||
res.setHeader('Content-Length', String(file.content.length));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||
// VFS artifacts are mutable (a re-run can overwrite a name), so they must NOT be
|
||||
// cached immutably the way content-addressed /image/{hash} is.
|
||||
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
res.setHeader('ETag', `W/"${file.updated_at || file.name}"`);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
|
||||
);
|
||||
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
return res.status(200).send(file.content);
|
||||
} catch (error) {
|
||||
const status = vfsFileErrorStatus(error);
|
||||
if (status === 500) console.error('Get vfs file failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function createUnderParent({ googleAuthId, parentId, name, track_stock }) {
|
||||
export async function createUnderParent({ googleAuthId, parentId, name, track_stock, sku }) {
|
||||
const store = await getStore();
|
||||
return store.article.createUnderParent({ googleAuthId, parentId, name, track_stock });
|
||||
return store.article.createUnderParent({ googleAuthId, parentId, name, track_stock, sku });
|
||||
}
|
||||
|
||||
export async function getBySku(googleAuthId, sku) {
|
||||
|
||||
36
src/db/modelconversation.js
Normal file
36
src/db/modelconversation.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function list(googleAuthId) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.list(googleAuthId);
|
||||
}
|
||||
|
||||
export async function create(googleAuthId, data) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.create(googleAuthId, data);
|
||||
}
|
||||
|
||||
export async function get(googleAuthId, conversationId) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.get(googleAuthId, conversationId);
|
||||
}
|
||||
|
||||
export async function setState(googleAuthId, conversationId, state) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.setState(googleAuthId, conversationId, state);
|
||||
}
|
||||
|
||||
export async function listMessages(googleAuthId, conversationId) {
|
||||
const store = await getStore();
|
||||
return store.modelmessage.list(googleAuthId, conversationId);
|
||||
}
|
||||
|
||||
export async function addMessage(googleAuthId, conversationId, data) {
|
||||
const store = await getStore();
|
||||
return store.modelmessage.add(googleAuthId, conversationId, data);
|
||||
}
|
||||
|
||||
export async function remove(googleAuthId, conversationId) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.delete(googleAuthId, conversationId);
|
||||
}
|
||||
26
src/db/modelvfs.js
Normal file
26
src/db/modelvfs.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function write(googleAuthId, input) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.write(googleAuthId, input);
|
||||
}
|
||||
|
||||
export async function read(googleAuthId, name) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.read(googleAuthId, name);
|
||||
}
|
||||
|
||||
export async function list(googleAuthId) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.list(googleAuthId);
|
||||
}
|
||||
|
||||
export async function remove(googleAuthId, name) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.delete(googleAuthId, name);
|
||||
}
|
||||
|
||||
export async function reset(googleAuthId) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.reset(googleAuthId);
|
||||
}
|
||||
498
src/db/publicCatalog.js
Normal file
498
src/db/publicCatalog.js
Normal file
@@ -0,0 +1,498 @@
|
||||
import { getProvider } from './store.js';
|
||||
|
||||
export async function getCompanyCatalogMeta(companyId) {
|
||||
const provider = await getProvider();
|
||||
const result = await provider.query(
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
root_category_id AS rootCategoryId,
|
||||
default_language_id AS languageId,
|
||||
poscustomergroup_id AS posCustomerGroupId
|
||||
FROM company
|
||||
WHERE id = @companyId`,
|
||||
{ companyId }
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listCategories(companyId) {
|
||||
const company = await getCompanyCatalogMeta(companyId);
|
||||
if (!company) {
|
||||
throw new Error('Company not found');
|
||||
}
|
||||
if (!company.rootCategoryId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
const isSqlite = provider.dialect === 'sqlite3';
|
||||
|
||||
const sql = isSqlite
|
||||
? `WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_id, sort
|
||||
FROM category
|
||||
WHERE id = @rootId
|
||||
UNION ALL
|
||||
SELECT c.id, c.parent_id, c.sort
|
||||
FROM category c
|
||||
INNER JOIN tree t ON c.parent_id = t.id
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_id AS parentId,
|
||||
t.sort,
|
||||
ct.name,
|
||||
ct.slug,
|
||||
p.hash AS fullHash,
|
||||
p.thumbw AS thumbW,
|
||||
p.thumbh AS thumbH,
|
||||
p.thumbhash AS thumbHash
|
||||
FROM tree t
|
||||
LEFT JOIN categorytext ct
|
||||
ON ct.category_id = t.id AND ct.language_id = @languageId
|
||||
LEFT JOIN pic p ON p.id = (
|
||||
SELECT cp.pic_id
|
||||
FROM categorypic cp
|
||||
WHERE cp.category_id = t.id
|
||||
ORDER BY cp.sort, cp.id
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY t.sort, t.id`
|
||||
: `WITH tree AS (
|
||||
SELECT id, parent_id, sort
|
||||
FROM category
|
||||
WHERE id = @rootId
|
||||
UNION ALL
|
||||
SELECT c.id, c.parent_id, c.sort
|
||||
FROM category c
|
||||
INNER JOIN tree t ON c.parent_id = t.id
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_id AS parentId,
|
||||
t.sort,
|
||||
ct.name,
|
||||
ct.slug,
|
||||
p.hash AS fullHash,
|
||||
p.thumbw AS thumbW,
|
||||
p.thumbh AS thumbH,
|
||||
p.thumbhash AS thumbHash
|
||||
FROM tree t
|
||||
LEFT JOIN categorytext ct
|
||||
ON ct.category_id = t.id AND ct.language_id = @languageId
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 cp.pic_id
|
||||
FROM categorypic cp
|
||||
WHERE cp.category_id = t.id
|
||||
ORDER BY cp.sort ASC, cp.id ASC
|
||||
) first_pic
|
||||
LEFT JOIN pic p ON p.id = first_pic.pic_id
|
||||
ORDER BY t.sort, t.id`;
|
||||
|
||||
const result = await provider.query(sql, {
|
||||
rootId: company.rootCategoryId,
|
||||
languageId: company.languageId,
|
||||
});
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
parentId: row.parentId,
|
||||
sort: row.sort,
|
||||
name: row.name ?? '',
|
||||
slug: row.slug ?? '',
|
||||
fullHash: row.fullHash ?? null,
|
||||
thumbW: row.thumbW ?? null,
|
||||
thumbH: row.thumbH ?? null,
|
||||
thumbHash: row.thumbHash ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listArticles(companyId, { categoryId, limit = 50, offset = 0, search } = {}) {
|
||||
const company = await getCompanyCatalogMeta(companyId);
|
||||
if (!company) {
|
||||
throw new Error('Company not found');
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
const isSqlite = provider.dialect === 'sqlite3';
|
||||
|
||||
const safeLimit = Math.min(Math.max(1, Number(limit) || 50), 200);
|
||||
const safeOffset = Math.max(0, Number(offset) || 0);
|
||||
const trimmedSearch = typeof search === 'string' ? search.trim() : '';
|
||||
|
||||
const params = {
|
||||
companyId,
|
||||
languageId: company.languageId,
|
||||
posCustomerGroupId: company.posCustomerGroupId,
|
||||
limit: safeLimit,
|
||||
offset: safeOffset,
|
||||
};
|
||||
|
||||
if (categoryId != null) {
|
||||
params.categoryId = Number(categoryId);
|
||||
}
|
||||
if (trimmedSearch) {
|
||||
params.searchPattern = `%${trimmedSearch}%`;
|
||||
}
|
||||
|
||||
const whereClauses = [
|
||||
'a.company_id = @companyId',
|
||||
'a.fatherarticle_id IS NULL',
|
||||
];
|
||||
if (categoryId != null) {
|
||||
whereClauses.push('ca.category_id = @categoryId');
|
||||
}
|
||||
if (trimmedSearch) {
|
||||
whereClauses.push('(a.sku LIKE @searchPattern OR at.name LIKE @searchPattern)');
|
||||
}
|
||||
const whereSql = whereClauses.join(' AND ');
|
||||
|
||||
const fromClause = categoryId != null
|
||||
? 'categoryarticle ca INNER JOIN article a ON a.id = ca.article_id'
|
||||
: 'article a';
|
||||
|
||||
const sql = isSqlite
|
||||
? `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
pr.net,
|
||||
t.rate AS taxRate,
|
||||
a.track_stock AS trackStock,
|
||||
pic.hash AS fullHash,
|
||||
pic.thumbw AS thumbW,
|
||||
pic.thumbh AS thumbH,
|
||||
pic.thumbhash AS thumbHash,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName
|
||||
FROM ${fromClause}
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN price pr
|
||||
ON pr.article_id = a.id
|
||||
AND pr.customergroup_id = @posCustomerGroupId
|
||||
AND pr.minmultiplier = 1
|
||||
LEFT JOIN tax t ON t.id = pr.tax_id
|
||||
LEFT JOIN pic ON pic.id = (
|
||||
SELECT ap.pic_id
|
||||
FROM articlepic ap
|
||||
WHERE ap.article_id = a.id
|
||||
ORDER BY ap.sort, ap.id
|
||||
LIMIT 1
|
||||
)
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY a.id
|
||||
LIMIT @limit OFFSET @offset`
|
||||
: `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
pr.net,
|
||||
t.rate AS taxRate,
|
||||
a.track_stock AS trackStock,
|
||||
pic.hash AS fullHash,
|
||||
pic.thumbw AS thumbW,
|
||||
pic.thumbh AS thumbH,
|
||||
pic.thumbhash AS thumbHash,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName
|
||||
FROM ${fromClause}
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN price pr
|
||||
ON pr.article_id = a.id
|
||||
AND pr.customergroup_id = @posCustomerGroupId
|
||||
AND pr.minmultiplier = 1
|
||||
LEFT JOIN tax t ON t.id = pr.tax_id
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 ap.pic_id
|
||||
FROM articlepic ap
|
||||
WHERE ap.article_id = a.id
|
||||
ORDER BY ap.sort ASC, ap.id ASC
|
||||
) first_pic
|
||||
LEFT JOIN pic ON pic.id = first_pic.pic_id
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY a.id
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`;
|
||||
|
||||
const result = await provider.query(sql, params);
|
||||
|
||||
return result.rows.map((row) => {
|
||||
const net = row.net != null ? Number(row.net) : null;
|
||||
const taxRate = row.taxRate != null ? Number(row.taxRate) : null;
|
||||
const gross = net != null && taxRate != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
sku: row.sku,
|
||||
name: row.name ?? '',
|
||||
slug: row.slug ?? '',
|
||||
net,
|
||||
taxRate,
|
||||
gross,
|
||||
trackStock: Boolean(row.trackStock ?? 1),
|
||||
brand: row.brandName
|
||||
? {
|
||||
id: row.brandId ?? null,
|
||||
name: row.brandName,
|
||||
}
|
||||
: null,
|
||||
fullHash: row.fullHash ?? null,
|
||||
thumbW: row.thumbW ?? null,
|
||||
thumbH: row.thumbH ?? null,
|
||||
thumbHash: row.thumbHash ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getArticleBySku(companyId, sku) {
|
||||
const company = await getCompanyCatalogMeta(companyId);
|
||||
if (!company) {
|
||||
throw new Error('Company not found');
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
const isSqlite = provider.dialect === 'sqlite3';
|
||||
|
||||
const sql = isSqlite
|
||||
? `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
at.desc AS description,
|
||||
a.unitamount,
|
||||
a.weight,
|
||||
a.track_stock AS trackStock,
|
||||
u.si AS unitSi,
|
||||
u.basesi AS unitBasesi,
|
||||
u.basesifactor AS unitBasesifactor,
|
||||
ut.symbol AS unitSymbol,
|
||||
ut.name AS unitName,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName,
|
||||
brand_pic.hash AS brandFullHash,
|
||||
brand_pic.thumbw AS brandThumbW,
|
||||
brand_pic.thumbh AS brandThumbH,
|
||||
brand_pic.thumbhash AS brandThumbHash,
|
||||
a.is_father AS isFather,
|
||||
a.fatherarticle_id AS fatherArticleId,
|
||||
father.sku AS fatherSku
|
||||
FROM article a
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN unit u ON u.id = a.unit_id
|
||||
LEFT JOIN unittext ut
|
||||
ON ut.unit_id = u.id AND ut.language_id = @languageId
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
LEFT JOIN pic brand_pic ON brand_pic.id = (
|
||||
SELECT bp.pic_id
|
||||
FROM brandpic bp
|
||||
WHERE bp.brand_id = b.id
|
||||
ORDER BY bp.sort, bp.id
|
||||
LIMIT 1
|
||||
)
|
||||
LEFT JOIN article father ON father.id = a.fatherarticle_id
|
||||
WHERE a.sku = @sku AND a.company_id = @companyId`
|
||||
: `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
at.desc AS description,
|
||||
a.unitamount,
|
||||
a.weight,
|
||||
a.track_stock AS trackStock,
|
||||
u.si AS unitSi,
|
||||
u.basesi AS unitBasesi,
|
||||
u.basesifactor AS unitBasesifactor,
|
||||
ut.symbol AS unitSymbol,
|
||||
ut.name AS unitName,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName,
|
||||
brand_pic.hash AS brandFullHash,
|
||||
brand_pic.thumbw AS brandThumbW,
|
||||
brand_pic.thumbh AS brandThumbH,
|
||||
brand_pic.thumbhash AS brandThumbHash,
|
||||
a.is_father AS isFather,
|
||||
a.fatherarticle_id AS fatherArticleId,
|
||||
father.sku AS fatherSku
|
||||
FROM article a
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN unit u ON u.id = a.unit_id
|
||||
LEFT JOIN unittext ut
|
||||
ON ut.unit_id = u.id AND ut.language_id = @languageId
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 bp.pic_id
|
||||
FROM brandpic bp
|
||||
WHERE bp.brand_id = b.id
|
||||
ORDER BY bp.sort ASC, bp.id ASC
|
||||
) first_brand_pic
|
||||
LEFT JOIN pic brand_pic ON brand_pic.id = first_brand_pic.pic_id
|
||||
LEFT JOIN article father ON father.id = a.fatherarticle_id
|
||||
WHERE a.sku = @sku AND a.company_id = @companyId`;
|
||||
|
||||
const result = await provider.query(sql, {
|
||||
sku,
|
||||
companyId,
|
||||
languageId: company.languageId,
|
||||
});
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('Article not found');
|
||||
}
|
||||
|
||||
const articleId = row.id;
|
||||
|
||||
const [categoriesRes, photosRes, eansRes, pricesRes, variantsRes] = await Promise.all([
|
||||
provider.query(
|
||||
`SELECT ca.category_id AS categoryId, ct.name, ct.slug
|
||||
FROM categoryarticle ca
|
||||
LEFT JOIN categorytext ct ON ct.category_id = ca.category_id AND ct.language_id = @languageId
|
||||
WHERE ca.article_id = @articleId
|
||||
ORDER BY ca.category_id`,
|
||||
{ articleId, languageId: company.languageId }
|
||||
),
|
||||
provider.query(
|
||||
`SELECT
|
||||
ap.sort,
|
||||
p.hash AS fullHash,
|
||||
p.w AS fullW,
|
||||
p.h AS fullH,
|
||||
p.size AS fullSize,
|
||||
p.thumbw AS thumbW,
|
||||
p.thumbh AS thumbH,
|
||||
p.thumbhash AS thumbHash
|
||||
FROM articlepic ap
|
||||
INNER JOIN pic p ON p.id = ap.pic_id
|
||||
WHERE ap.article_id = @articleId
|
||||
ORDER BY ap.sort ASC, ap.id ASC`,
|
||||
{ articleId }
|
||||
),
|
||||
provider.query(
|
||||
`SELECT ae.ean
|
||||
FROM articleean ae
|
||||
WHERE ae.article_id = @articleId
|
||||
ORDER BY ae.ean`,
|
||||
{ articleId }
|
||||
),
|
||||
provider.query(
|
||||
`SELECT
|
||||
pr.minmultiplier,
|
||||
pr.net,
|
||||
t.rate AS taxRate
|
||||
FROM price pr
|
||||
INNER JOIN tax t ON t.id = pr.tax_id
|
||||
WHERE pr.article_id = @articleId
|
||||
AND pr.customergroup_id = @posCustomerGroupId
|
||||
ORDER BY pr.minmultiplier ASC`,
|
||||
{ articleId, posCustomerGroupId: company.posCustomerGroupId }
|
||||
),
|
||||
row.isFather
|
||||
? provider.query(
|
||||
`SELECT
|
||||
child.id,
|
||||
child.sku,
|
||||
child.fathersort,
|
||||
child.track_stock AS trackStock,
|
||||
child.unitamount,
|
||||
child.weight,
|
||||
at_child.name,
|
||||
at_child.slug,
|
||||
av.id AS attributeValueId,
|
||||
av.value AS attributeValue
|
||||
FROM article child
|
||||
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = @languageId
|
||||
LEFT JOIN articleattributevalue aav ON aav.article_id = child.id
|
||||
LEFT JOIN attributevalue av ON av.id = aav.attributevalue_id
|
||||
WHERE child.fatherarticle_id = @articleId
|
||||
ORDER BY child.fathersort ASC, child.id ASC`,
|
||||
{ articleId, languageId: company.languageId }
|
||||
)
|
||||
: Promise.resolve({ rows: [] }),
|
||||
]);
|
||||
|
||||
const prices = pricesRes.rows.map((p) => {
|
||||
const net = p.net != null ? Number(p.net) : null;
|
||||
const taxRate = p.taxRate != null ? Number(p.taxRate) : null;
|
||||
const gross = net != null && taxRate != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null;
|
||||
return {
|
||||
minmultiplier: p.minmultiplier,
|
||||
net,
|
||||
taxRate,
|
||||
gross,
|
||||
};
|
||||
});
|
||||
|
||||
const primaryPrice = prices[0] ?? null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
sku: row.sku,
|
||||
name: row.name ?? '',
|
||||
slug: row.slug ?? '',
|
||||
description: row.description ?? '',
|
||||
unitamount: row.unitamount,
|
||||
weight: row.weight,
|
||||
trackStock: Boolean(row.trackStock ?? 1),
|
||||
unit: row.unitSi
|
||||
? {
|
||||
si: row.unitSi,
|
||||
basesi: row.unitBasesi,
|
||||
basesifactor: row.unitBasesifactor,
|
||||
symbol: row.unitSymbol ?? null,
|
||||
name: row.unitName ?? null,
|
||||
}
|
||||
: null,
|
||||
brand: row.brandName
|
||||
? {
|
||||
id: row.brandId ?? null,
|
||||
name: row.brandName,
|
||||
fullHash: row.brandFullHash ?? null,
|
||||
thumbW: row.brandThumbW ?? null,
|
||||
thumbH: row.brandThumbH ?? null,
|
||||
thumbHash: row.brandThumbHash ?? null,
|
||||
}
|
||||
: null,
|
||||
price: primaryPrice,
|
||||
prices,
|
||||
categories: categoriesRes.rows.map((c) => ({
|
||||
id: c.categoryId,
|
||||
name: c.name ?? '',
|
||||
slug: c.slug ?? '',
|
||||
})),
|
||||
photos: photosRes.rows.map((p) => ({
|
||||
sort: p.sort,
|
||||
fullHash: p.fullHash,
|
||||
fullW: p.fullW,
|
||||
fullH: p.fullH,
|
||||
fullSize: p.fullSize,
|
||||
thumbW: p.thumbW,
|
||||
thumbH: p.thumbH,
|
||||
thumbHash: p.thumbHash,
|
||||
})),
|
||||
eans: eansRes.rows.map((e) => e.ean),
|
||||
isFather: Boolean(row.isFather),
|
||||
fatherSku: row.fatherSku ?? null,
|
||||
variants: variantsRes.rows.map((v) => ({
|
||||
id: v.id,
|
||||
sku: v.sku,
|
||||
sort: v.fathersort,
|
||||
name: v.name ?? '',
|
||||
slug: v.slug ?? '',
|
||||
attributeValueId: v.attributeValueId ?? null,
|
||||
attributeValue: v.attributeValue ?? null,
|
||||
unitamount: v.unitamount,
|
||||
weight: v.weight,
|
||||
trackStock: Boolean(v.trackStock ?? 1),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,8 @@ const STORE_NAMESPACES = [
|
||||
'attributevalue',
|
||||
'articleattributevalue',
|
||||
'categoryattributevalue',
|
||||
'modelconversation',
|
||||
'modelmessage',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -148,6 +150,11 @@ export async function getStore() {
|
||||
return connecting;
|
||||
}
|
||||
|
||||
export async function getProvider() {
|
||||
await getStore();
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function ensureConnection() {
|
||||
await getStore();
|
||||
}
|
||||
@@ -159,3 +166,4 @@ export async function closeStore() {
|
||||
store = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
522
src/services/conversationManager.js
Normal file
522
src/services/conversationManager.js
Normal file
@@ -0,0 +1,522 @@
|
||||
import { LLMOrbiter, OpenRouterProvider } from 'libllmorbiter';
|
||||
import * as db from '../db/modelconversation.js';
|
||||
import * as dbCompanySetting from '../db/companysetting.js';
|
||||
import env from '../config/env.js';
|
||||
import { emit as emitCompanyEvent } from '../api/sse.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createDashbackRequestTool } from '../tools/dashback-request.js';
|
||||
import { createAskUserTool } from '../tools/ask-user.js';
|
||||
import { createJavascriptExecuteTool } from '../tools/javascript.js';
|
||||
import { createResetVfsTool } from '../tools/reset-vfs.js';
|
||||
|
||||
const KEEP_ALIVE_MS = 20_000;
|
||||
|
||||
const ROUTES_DOC_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../../docs/api/routes.md');
|
||||
|
||||
/**
|
||||
* System prompt: base instructions plus the generated API routes reference
|
||||
* (docs/api/routes.md), read on each run so it stays current.
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildSystemPrompt() {
|
||||
let routesDoc = '';
|
||||
try {
|
||||
routesDoc = readFileSync(ROUTES_DOC_PATH, 'utf8').trim();
|
||||
} catch (err) {
|
||||
console.error('conversation: failed to read routes doc:', err);
|
||||
}
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return [
|
||||
'You are an intelligent assistant specialized in exploring, querying, and managing data from the Dashback REST API.',
|
||||
`Today's date: ${today}`,
|
||||
'',
|
||||
'YOUR TOOLS:',
|
||||
'1. dashback_request: Execute HTTP requests (GET, POST, PUT, PATCH, DELETE) against Dashback endpoints.',
|
||||
'2. ask_user: Ask clarifying questions or request decisions from the user if their intent is ambiguous or requires choices. When you call ask_user, execution pauses immediately and waits for the user to provide their response.',
|
||||
'3. javascript_execute: Run JavaScript snippets in a sandbox to compute, aggregate, transform, or analyze data. Assign module.exports to return a value. With allow_network: true, snippets can directly call Dashback API routes using fetch() (e.g. await fetch("/article/2")) and search images using search_for_images(query). A sandboxed virtual filesystem (fs / vfs) is available to store and retrieve artifacts across runs.',
|
||||
'4. reset_vfs: Delete all files, images, and artifacts stored in the virtual filesystem (VFS) to free up storage or clean up.',
|
||||
'',
|
||||
'OPERATING GUIDELINES:',
|
||||
'- Refer to the DASHBACK API ROUTES documentation below to find appropriate endpoints, parameters, and HTTP methods.',
|
||||
'- SELLING PRICES: In article responses (GET /article/{sku} and returned article), the selling price field is named net (NOT price). The structure of entries in article.prices is { minmultiplier, net, taxRate, customergroupId }.',
|
||||
'- GROSS vs NET PRICES: Users almost always mean GROSS (brutto, incl. VAT) prices when they specify a price (e.g. "1,60 €"), but the API stores and accepts NET prices only. Convert: net = gross / (1 + taxRate/100). Send net with high precision (at least 4–6 decimal places) so rounding back gives the exact gross. Calculate this in javascript_execute.',
|
||||
'- DATA TYPES & WIRE FORMAT: Dashback strictly validates types (numeric values like weight, price, quantity, IDs must be numbers; flags must be booleans).',
|
||||
'- VIRTUAL FILESYSTEM & ARTIFACT PERSISTENCE: Inside javascript_execute, you have access to a persistent virtual filesystem via globals fs and vfs. Artifacts persist across runs for the company.',
|
||||
'- SHOWING GENERATED IMAGES: Markdown images ARE supported and render inline in the chat. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)` or `sharp(...).toFile("images/example.png")`), reference the exact path you wrote with standard markdown ``, which renders inline. The title text is the alt text, so make it descriptive. Use this whenever you generate or modify an image so the user actually sees the result. Other file types (PDF, JSON, CSV) are NOT rendered inline — link to them instead and offer them as a download. NOTE: the `image/` folder is reserved for database images and is read-only; write generated images anywhere else.',
|
||||
'- LABEL TEMPLATES (/labeltemplate): Label templates are NOT ZPL. content is Dashback\'s JSON label schema: { "objects": [ ... ] }. Do NOT write or suggest ZPL.',
|
||||
'- Format responses clearly using markdown tables, bullet lists, or bold key-value pairs where appropriate.',
|
||||
...(routesDoc ? ['', 'DASHBACK API ROUTES:', routesDoc] : []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenRouter API key: company setting `keys.openrouter`, then the
|
||||
* server-wide `OPENROUTER_FREE_KEY` (same order as delivery parsing).
|
||||
* @param {number} googleAuthId
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function resolveOpenRouterKey(googleAuthId) {
|
||||
let apiKey;
|
||||
try {
|
||||
const setting = await dbCompanySetting.getByKey(googleAuthId, 'keys.openrouter', { maskWriteonly: false });
|
||||
apiKey = setting?.value?.trim();
|
||||
} catch (err) {
|
||||
console.error('conversation: failed to read OpenRouter key:', err);
|
||||
}
|
||||
if (!apiKey) apiKey = env.openrouterFreeKey?.trim();
|
||||
if (!apiKey) throw new Error('OpenRouter API key not configured (companysetting: keys.openrouter)');
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-conversation manager. Owns the SSE subscribers of one conversation and
|
||||
* runs the LLM (via LLMOrbiter) for each posted user message.
|
||||
*
|
||||
* Persistence: the database holds the transcript (`modelmessage`) and a binary
|
||||
* `idle`/`busy` state. The finer SSE states (`thinking`, `working`, `done`)
|
||||
* and the streamed `delta` chunks are SSE-only.
|
||||
*/
|
||||
export function toLlmMessage(m) {
|
||||
let payload = {};
|
||||
if (typeof m.message === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(m.message);
|
||||
} catch {
|
||||
payload = { content: m.message };
|
||||
}
|
||||
} else if (m.message && typeof m.message === 'object') {
|
||||
payload = m.message;
|
||||
}
|
||||
|
||||
if (m.role === 'user') {
|
||||
return { role: 'user', content: payload.content ?? (typeof m.message === 'string' ? m.message : '') };
|
||||
}
|
||||
if (m.role === 'assistant') {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
content: payload.content ?? null,
|
||||
};
|
||||
if (Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0) {
|
||||
msg.tool_calls = payload.tool_calls;
|
||||
}
|
||||
if (Array.isArray(payload.reasoning_details) && payload.reasoning_details.length > 0) {
|
||||
msg.reasoning_details = payload.reasoning_details;
|
||||
}
|
||||
if (payload.reasoning) {
|
||||
msg.reasoning = payload.reasoning;
|
||||
} else if (payload.reasoning_content) {
|
||||
msg.reasoning = payload.reasoning_content;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
if (m.role === 'tool') {
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: payload.tool_call_id ?? '',
|
||||
content: typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content ?? ''),
|
||||
...(payload.name ? { name: payload.name } : {}),
|
||||
};
|
||||
}
|
||||
return { role: m.role, content: payload.content ?? m.message };
|
||||
}
|
||||
|
||||
export class ConversationManager {
|
||||
/**
|
||||
* @param {number} conversationId
|
||||
* @param {{ companyId: number, googleAuthId: number }} meta
|
||||
*/
|
||||
constructor(conversationId, { companyId, googleAuthId }) {
|
||||
this.conversationId = conversationId;
|
||||
this.companyId = companyId;
|
||||
this.googleAuthId = googleAuthId;
|
||||
/** @type {Set<{ res: import('express').Response, keepAlive: NodeJS.Timeout }>} */
|
||||
this.subscribers = new Set();
|
||||
/** @type {LLMOrbiter|null} */
|
||||
this.orbiter = null;
|
||||
this.destroyed = false;
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an SSE event to all subscribers of this conversation.
|
||||
* @param {string} event
|
||||
* @param {object} data
|
||||
*/
|
||||
broadcast(event, data) {
|
||||
if (this.destroyed) return;
|
||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const client of this.subscribers) {
|
||||
if (!client.res.writableEnded && !client.res.destroyed) {
|
||||
client.res.write(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an SSE subscriber for this conversation.
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').Request} [req]
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
subscribe(res, req) {
|
||||
req?.socket?.setTimeout(0);
|
||||
res.setTimeout?.(0);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
res.write(': ping\n\n');
|
||||
|
||||
const client = {
|
||||
res,
|
||||
keepAlive: setInterval(() => {
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.write(': heartbeat\n\n');
|
||||
}, KEEP_ALIVE_MS),
|
||||
};
|
||||
this.subscribers.add(client);
|
||||
|
||||
const unsubscribe = () => {
|
||||
clearInterval(client.keepAlive);
|
||||
this.subscribers.delete(client);
|
||||
};
|
||||
|
||||
res.on('close', unsubscribe);
|
||||
req?.on('close', unsubscribe);
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming user message.
|
||||
* 1. Persists the user message (DB refuses unless `idle`).
|
||||
* 2. Broadcasts it, marks the conversation `busy` (DB) and `thinking` (SSE).
|
||||
* 3. Starts the LLM run in the background; the HTTP request returns immediately.
|
||||
*
|
||||
* @param {number} googleAuthId
|
||||
* @param {{ message: string }} input
|
||||
* @returns {Promise<object>} The created user message
|
||||
* @throws {Error} 'Conversation is not idle' while a run is active.
|
||||
*/
|
||||
async handleUserMessage(googleAuthId, { message }) {
|
||||
if (this.isProcessing) throw new Error('Conversation is not idle');
|
||||
this.isProcessing = true;
|
||||
|
||||
let userMsg;
|
||||
try {
|
||||
const rawContent = typeof message === 'string' ? message : (message?.content || '');
|
||||
const payload = JSON.stringify({ content: rawContent });
|
||||
userMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'user' });
|
||||
await db.setState(googleAuthId, this.conversationId, 'busy');
|
||||
} catch (err) {
|
||||
this.isProcessing = false;
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.broadcast('message', userMsg);
|
||||
this.broadcast('state', { state: 'thinking' });
|
||||
emitCompanyEvent('modelconversation', { companyId: this.companyId });
|
||||
|
||||
this.runGeneration(googleAuthId).catch((err) => {
|
||||
console.error(`[ConversationManager ${this.conversationId}] unexpected generation error:`, err);
|
||||
});
|
||||
|
||||
return userMsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming tool response message (e.g. from the user answering ask_user).
|
||||
* 1. Persists the tool response message (DB refuses unless `idle`).
|
||||
* 2. Broadcasts it, marks the conversation `busy` (DB) and `working` (SSE).
|
||||
* 3. Starts the LLM run in the background; the HTTP request returns immediately.
|
||||
*
|
||||
* @param {number} googleAuthId
|
||||
* @param {{ tool_call_id: string, content?: any, answer?: any }} input
|
||||
* @returns {Promise<object>} The created tool message
|
||||
* @throws {Error} 'Conversation is not idle' while a run is active.
|
||||
*/
|
||||
async handleToolMessage(googleAuthId, { tool_call_id, content, answer }) {
|
||||
if (!tool_call_id) throw new Error('tool_call_id is required');
|
||||
if (this.isProcessing) throw new Error('Conversation is not idle');
|
||||
this.isProcessing = true;
|
||||
|
||||
let toolMsg;
|
||||
try {
|
||||
const rawContent = content !== undefined
|
||||
? (typeof content === 'string' ? content : JSON.stringify(content))
|
||||
: JSON.stringify({ answer: answer ?? '' });
|
||||
const payload = JSON.stringify({ content: rawContent, tool_call_id });
|
||||
toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' });
|
||||
await db.setState(googleAuthId, this.conversationId, 'busy');
|
||||
} catch (err) {
|
||||
this.isProcessing = false;
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.broadcast('message', toolMsg);
|
||||
this.broadcast('state', { state: 'working' });
|
||||
emitCompanyEvent('modelconversation', { companyId: this.companyId });
|
||||
|
||||
this.runGeneration(googleAuthId).catch((err) => {
|
||||
console.error(`[ConversationManager ${this.conversationId}] unexpected generation error:`, err);
|
||||
});
|
||||
|
||||
return toolMsg;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run the LLM over the stored transcript, stream the answer over SSE,
|
||||
* persist reasoning, tool calls, and assistant messages, then return to `idle`.
|
||||
* @param {number} googleAuthId
|
||||
*/
|
||||
async runGeneration(googleAuthId) {
|
||||
let usage = null;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const conversation = await db.get(googleAuthId, this.conversationId);
|
||||
if (conversation.provider !== 'openrouter') {
|
||||
throw new Error(`Unsupported provider: ${conversation.provider}`);
|
||||
}
|
||||
const apiKey = await resolveOpenRouterKey(googleAuthId);
|
||||
|
||||
// Transcript comes from the database (includes user, assistant, tool, reasoning).
|
||||
const history = await db.listMessages(googleAuthId, this.conversationId);
|
||||
const messages = [
|
||||
{ role: 'system', content: buildSystemPrompt() },
|
||||
...history.map(toLlmMessage),
|
||||
];
|
||||
|
||||
let working = false;
|
||||
let text = '';
|
||||
let currentReasoning = '';
|
||||
let savedToolCallsTurn = null;
|
||||
|
||||
const markWorking = () => {
|
||||
if (working) return;
|
||||
working = true;
|
||||
this.broadcast('state', { state: 'working' });
|
||||
};
|
||||
|
||||
this.orbiter = new LLMOrbiter(new OpenRouterProvider({ apiKey }), {
|
||||
onStatus: (status) => {
|
||||
if (status === 'working') markWorking();
|
||||
},
|
||||
onReasoning: (reasoning) => {
|
||||
markWorking();
|
||||
currentReasoning = reasoning;
|
||||
this.broadcast('reasoning', { text: reasoning });
|
||||
},
|
||||
onContent: (chunk) => {
|
||||
markWorking();
|
||||
text += chunk;
|
||||
this.broadcast('delta', { delta: chunk, text });
|
||||
},
|
||||
onToolCall: async ({ name, args, id, callItem, tool_calls, reasoning_details, reasoning, content }) => {
|
||||
markWorking();
|
||||
try {
|
||||
const effectiveToolCalls = tool_calls || (callItem ? [callItem] : [{
|
||||
id: id || randomUUID(),
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: typeof args === 'string' ? args : JSON.stringify(args || {}),
|
||||
},
|
||||
}]);
|
||||
|
||||
if (savedToolCallsTurn !== effectiveToolCalls) {
|
||||
savedToolCallsTurn = effectiveToolCalls;
|
||||
const effectiveReasoningDetails = (Array.isArray(reasoning_details) && reasoning_details.length > 0)
|
||||
? reasoning_details
|
||||
: undefined;
|
||||
const effectiveReasoning = reasoning || currentReasoning.trim() || undefined;
|
||||
currentReasoning = '';
|
||||
|
||||
const assistantPayload = {
|
||||
content: (content || text).trim() || '',
|
||||
tool_calls: effectiveToolCalls,
|
||||
...(effectiveReasoningDetails ? { reasoning_details: effectiveReasoningDetails } : {}),
|
||||
...(effectiveReasoning ? { reasoning: effectiveReasoning } : {}),
|
||||
};
|
||||
|
||||
const callMsg = await db.addMessage(googleAuthId, this.conversationId, {
|
||||
role: 'assistant',
|
||||
message: JSON.stringify(assistantPayload),
|
||||
});
|
||||
text = '';
|
||||
this.broadcast('message', callMsg);
|
||||
emitCompanyEvent('modelconversation', { companyId: this.companyId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[ConversationManager ${this.conversationId}] failed to save tool call message:`, err);
|
||||
}
|
||||
},
|
||||
onToolResult: async ({ name, result, id, callItem }) => {
|
||||
try {
|
||||
const resMsg = await db.addMessage(googleAuthId, this.conversationId, {
|
||||
role: 'tool',
|
||||
message: JSON.stringify({
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result),
|
||||
tool_call_id: id || callItem?.id || '',
|
||||
}),
|
||||
});
|
||||
this.broadcast('message', resMsg);
|
||||
emitCompanyEvent('modelconversation', { companyId: this.companyId });
|
||||
} catch (err) {
|
||||
console.error(`[ConversationManager ${this.conversationId}] failed to save tool result message:`, err);
|
||||
}
|
||||
},
|
||||
onToolError: async ({ name, error, id, callItem }) => {
|
||||
try {
|
||||
const errMsg = await db.addMessage(googleAuthId, this.conversationId, {
|
||||
role: 'tool',
|
||||
message: JSON.stringify({
|
||||
content: `Tool error [${name}]: ${error}`,
|
||||
tool_call_id: id || callItem?.id || '',
|
||||
}),
|
||||
});
|
||||
this.broadcast('message', errMsg);
|
||||
emitCompanyEvent('modelconversation', { companyId: this.companyId });
|
||||
} catch (err) {
|
||||
console.error(`[ConversationManager ${this.conversationId}] failed to save tool error message:`, err);
|
||||
}
|
||||
},
|
||||
onUsage: (u) => {
|
||||
console.log(
|
||||
`[conversation ${this.conversationId}] usage: input=${u.input}, cached=${u.cached}, output=${u.output}` +
|
||||
(u.cost != null ? `, cost=$${u.cost.toFixed(4)}` : '')
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await this.orbiter.run({
|
||||
messages,
|
||||
model: conversation.model,
|
||||
tools: [
|
||||
createDashbackRequestTool({ googleAuthId }),
|
||||
createAskUserTool(),
|
||||
createJavascriptExecuteTool({ googleAuthId, companyId: this.companyId }),
|
||||
createResetVfsTool({ googleAuthId, companyId: this.companyId }),
|
||||
],
|
||||
});
|
||||
|
||||
if (this.destroyed) return;
|
||||
|
||||
// Direct return (e.g. from ask_user) halts without adding an additional assistant message.
|
||||
if (result?.directReturn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = (result.content ?? '').trim();
|
||||
const finalReasoningDetails = (Array.isArray(result.reasoning_details) && result.reasoning_details.length > 0)
|
||||
? result.reasoning_details
|
||||
: undefined;
|
||||
const finalReasoning = result.reasoning || currentReasoning.trim() || undefined;
|
||||
|
||||
if (content || finalReasoningDetails || finalReasoning) {
|
||||
const assistantMsg = await db.addMessage(googleAuthId, this.conversationId, {
|
||||
message: JSON.stringify({
|
||||
content: content || '',
|
||||
...(finalReasoningDetails ? { reasoning_details: finalReasoningDetails } : {}),
|
||||
...(finalReasoning ? { reasoning: finalReasoning } : {}),
|
||||
}),
|
||||
role: 'assistant',
|
||||
});
|
||||
this.broadcast('message', assistantMsg);
|
||||
}
|
||||
|
||||
usage = {
|
||||
input: result.usage.input,
|
||||
cached: result.usage.cached,
|
||||
output: result.usage.output,
|
||||
cost: result.usage.cost,
|
||||
};
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
error = err.message;
|
||||
console.error(`[ConversationManager ${this.conversationId}] generation failed:`, err);
|
||||
} finally {
|
||||
this.orbiter = null;
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.setState(googleAuthId, this.conversationId, 'idle');
|
||||
} catch (err) {
|
||||
console.error(`[ConversationManager ${this.conversationId}] failed to reset state:`, err);
|
||||
}
|
||||
emitCompanyEvent('modelconversation', { companyId: this.companyId });
|
||||
this.broadcast('state', error ? { state: 'done', error } : { state: 'done', usage });
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.orbiter?.abort('Conversation manager closed');
|
||||
for (const client of this.subscribers) {
|
||||
clearInterval(client.keepAlive);
|
||||
if (!client.res.writableEnded && !client.res.destroyed) {
|
||||
client.res.end();
|
||||
}
|
||||
}
|
||||
this.subscribers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Map<number, ConversationManager>} */
|
||||
const activeManagers = new Map();
|
||||
|
||||
/**
|
||||
* Get or create an active ConversationManager for a conversation.
|
||||
* @param {number} conversationId
|
||||
* @param {{ companyId: number, googleAuthId: number }} meta
|
||||
* @returns {ConversationManager}
|
||||
*/
|
||||
export function getOrCreateManager(conversationId, { companyId, googleAuthId }) {
|
||||
let manager = activeManagers.get(conversationId);
|
||||
if (!manager) {
|
||||
manager = new ConversationManager(conversationId, { companyId, googleAuthId });
|
||||
activeManagers.set(conversationId, manager);
|
||||
} else {
|
||||
if (companyId) manager.companyId = companyId;
|
||||
if (googleAuthId) manager.googleAuthId = googleAuthId;
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active ConversationManager if one exists.
|
||||
* @param {number} conversationId
|
||||
* @returns {ConversationManager|undefined}
|
||||
*/
|
||||
export function getActiveManager(conversationId) {
|
||||
return activeManagers.get(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close, destroy, and remove an active ConversationManager if one exists.
|
||||
* @param {number} conversationId
|
||||
*/
|
||||
export function removeManager(conversationId) {
|
||||
const manager = activeManagers.get(conversationId);
|
||||
if (manager) {
|
||||
manager.destroy();
|
||||
activeManagers.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and destroy all active conversation managers.
|
||||
*/
|
||||
export function closeAllManagers() {
|
||||
for (const [id, manager] of activeManagers.entries()) {
|
||||
manager.destroy();
|
||||
activeManagers.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const FETCH_TIMEOUT_MS = 15000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
const MAX_EDGE = 1000;
|
||||
const TRIM_THRESHOLD = 10;
|
||||
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
|
||||
const AVIF_QUALITY = 80;
|
||||
const AVIF_EFFORT = 4;
|
||||
|
||||
@@ -79,7 +80,10 @@ export async function crawlImage(url, { signal } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims transparent and white borders from an image buffer, resizes to max 1000px, and converts to AVIF.
|
||||
* Trims transparent and white borders from an image buffer, restores up to
|
||||
* RESTORE_MARGIN px of the original image on each edge (never more than was
|
||||
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
|
||||
* and converts to AVIF.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Raw image buffer
|
||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||
@@ -88,56 +92,62 @@ export async function cropAndConvertToAvif(inputBuffer) {
|
||||
// 1. Initial metadata and orientation correction
|
||||
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
|
||||
const initialMeta = await baseSharp.metadata();
|
||||
const hasAlpha = Boolean(initialMeta.hasAlpha);
|
||||
|
||||
// Use PNG intermediate for trimming passes to preserve alpha
|
||||
let currentBuffer = await baseSharp.png().toBuffer();
|
||||
let currentMeta = initialMeta;
|
||||
const originalBuffer = await baseSharp.png().toBuffer();
|
||||
const origMeta = await sharp(originalBuffer).metadata();
|
||||
|
||||
// Cumulative crop box in original (oriented) pixel coordinates
|
||||
let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height };
|
||||
let currentBuffer = originalBuffer;
|
||||
|
||||
const trimPass = async (background) => {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
box = {
|
||||
left: box.left - (trimmed.info.trimOffsetLeft ?? 0),
|
||||
top: box.top - (trimmed.info.trimOffsetTop ?? 0),
|
||||
width: trimmed.info.width,
|
||||
height: trimmed.info.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Step A: Trim transparent borders if alpha is present
|
||||
if (currentMeta.hasAlpha) {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
}
|
||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
||||
|
||||
// 3. Step B: Trim white borders
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: '#ffffff', threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
await trimPass('#ffffff');
|
||||
|
||||
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
|
||||
if (currentMeta.hasAlpha) {
|
||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
||||
|
||||
// 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds
|
||||
const left = Math.max(0, box.left - RESTORE_MARGIN);
|
||||
const top = Math.max(0, box.top - RESTORE_MARGIN);
|
||||
const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN);
|
||||
const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN);
|
||||
if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
|
||||
const restored = await sharp(originalBuffer)
|
||||
.extract({ left, top, width: right - left, height: bottom - top })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
currentBuffer = restored.data;
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
}
|
||||
const currentMeta = await sharp(currentBuffer).metadata();
|
||||
|
||||
// 5. Resize to max 1000px without enlargement
|
||||
let finalPipeline = sharp(currentBuffer);
|
||||
|
||||
43
src/tools/ask-user.js
Normal file
43
src/tools/ask-user.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineTool, DirectReturnError } from 'libllmorbiter';
|
||||
|
||||
/**
|
||||
* ask_user tool:
|
||||
* Allows the LLM to ask the user for clarification or a decision (e.g. which SKU or ID
|
||||
* they meant, or what parameter to use).
|
||||
*
|
||||
* When invoked, it raises a DirectReturnError with recordResult: false so the LLMOrbiter
|
||||
* halts the inference loop immediately without generating a synthetic tool response.
|
||||
* The conversation transitions into `idle` state, allowing the client/user to submit
|
||||
* a tool response via the API.
|
||||
*/
|
||||
export function createAskUserTool() {
|
||||
return defineTool({
|
||||
name: 'ask_user',
|
||||
description:
|
||||
'Ask the user when you need clarification or a decision (e.g. which SKU or ID they meant, or what parameter to use). ' +
|
||||
'Give a concise question + up to 3 short options (A,B,C); the user may select an option or provide free-form text.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['question'],
|
||||
properties: {
|
||||
question: {
|
||||
type: 'string',
|
||||
description: 'Question for the user.',
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Up to 3 short option labels.',
|
||||
items: { type: 'string' },
|
||||
maxItems: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
invoke: async ({ question, options = [] }) => {
|
||||
const capped = Array.isArray(options) ? options.slice(0, 3) : [];
|
||||
// Stop LLM loop immediately so the conversation stays idle awaiting user tool response
|
||||
throw new DirectReturnError({ question, options: capped }, { recordResult: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
57
src/tools/dashback-request.js
Normal file
57
src/tools/dashback-request.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { defineTool } from 'libllmorbiter';
|
||||
import env from '../config/env.js';
|
||||
|
||||
const MAX_RESPONSE_CHARS = 20_000;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
|
||||
/**
|
||||
* Create an LLM tool that performs HTTP requests against this API, authenticated
|
||||
* as the given user (short-lived JWT, same auth path as browser requests).
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
*/
|
||||
export function createDashbackRequestTool({ googleAuthId }) {
|
||||
return defineTool({
|
||||
name: 'dashback_request',
|
||||
description:
|
||||
'Interrogate and call the Dashback REST API. Supports GET requests directly. ' +
|
||||
'Paths are relative to the API root (e.g. "/article/2", "/category/tree", "/brand").',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['method', 'path'],
|
||||
properties: {
|
||||
method: { type: 'string', enum: METHODS, description: 'HTTP method' },
|
||||
path: { type: 'string', description: 'Path of the endpoint to request (e.g. "/article/2", "/category/tree", "/brand").' },
|
||||
query: { type: 'object', description: 'Optional query parameters as key-value pairs (e.g. { limit: 10, offset: 0 }).' },
|
||||
body: { type: 'string', description: 'Raw JSON request payload string (e.g. \'{"weight": 2.5}\').' },
|
||||
},
|
||||
},
|
||||
invoke: async ({ method = 'GET', path, query = null, body = null }) => {
|
||||
if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`);
|
||||
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
|
||||
throw new Error('path must start with a single "/"');
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) };
|
||||
if (body != null && method !== 'GET' && method !== 'DELETE') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
const qs = query ? `?${new URLSearchParams(query).toString()}` : '';
|
||||
const url = `${path.startsWith('/docs') || path.startsWith('/api/') ? path : `/api${path}`}${qs}`;
|
||||
console.log(`[llmorbiter] Tool invoke dashback_request: ${method} ${url}`);
|
||||
const res = await fetch(`http://127.0.0.1:${env.port}${url}`, init);
|
||||
let text = await res.text();
|
||||
if (text.length > MAX_RESPONSE_CHARS) {
|
||||
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
|
||||
}
|
||||
return JSON.stringify({ status: res.status, body: text });
|
||||
},
|
||||
});
|
||||
}
|
||||
413
src/tools/javascript-worker.js
Normal file
413
src/tools/javascript-worker.js
Normal file
@@ -0,0 +1,413 @@
|
||||
'use strict';
|
||||
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import { NodeVM } from 'vm2';
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import fsModule from 'fs';
|
||||
import { validateObjects, validateObject } from 'react-canvas-label-editor';
|
||||
import { createVfs } from './vfs.js';
|
||||
|
||||
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
|
||||
|
||||
function resolveDashbackUrl(inputUrl, baseApiUrl) {
|
||||
const raw = String(inputUrl || '').trim();
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
||||
return raw;
|
||||
}
|
||||
const cleanPath = raw.startsWith('/') ? raw : '/' + raw;
|
||||
const base = new URL(baseApiUrl || 'http://127.0.0.1:4208/api');
|
||||
const origin = base.origin;
|
||||
|
||||
if (cleanPath.startsWith('/api/') || cleanPath === '/api' || cleanPath.startsWith('/v1/') || cleanPath === '/v1') {
|
||||
return new URL(cleanPath, origin).toString();
|
||||
}
|
||||
const basePath = base.pathname.replace(/\/+$/, '');
|
||||
return new URL(`${basePath}${cleanPath}`, origin).toString();
|
||||
}
|
||||
|
||||
function isDashbackTarget(targetUrl, baseApiUrl) {
|
||||
if (!baseApiUrl) return false;
|
||||
try {
|
||||
const targetObj = new URL(targetUrl);
|
||||
const baseObj = new URL(baseApiUrl);
|
||||
return targetObj.origin === baseObj.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createSandboxFetch(apiUrl, token) {
|
||||
const rawFetch = globalThis.fetch;
|
||||
return async function sandboxFetch(input, init) {
|
||||
let url;
|
||||
let isDashback = false;
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const resolved = resolveDashbackUrl(input, apiUrl);
|
||||
url = resolved;
|
||||
isDashback = isDashbackTarget(resolved, apiUrl);
|
||||
} else if (input instanceof globalThis.Request) {
|
||||
url = input.url;
|
||||
isDashback = isDashbackTarget(url, apiUrl);
|
||||
} else if (input instanceof globalThis.URL) {
|
||||
url = input.toString();
|
||||
isDashback = isDashbackTarget(url, apiUrl);
|
||||
} else {
|
||||
url = input;
|
||||
}
|
||||
|
||||
if (isDashback && token) {
|
||||
const existingHeaders = init?.headers || (typeof input === 'object' && input?.headers ? input.headers : {});
|
||||
const headers = new globalThis.Headers(existingHeaders);
|
||||
if (!headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json, text/plain, */*');
|
||||
}
|
||||
return rawFetch(input instanceof globalThis.Request ? input : url, { ...init, headers });
|
||||
}
|
||||
|
||||
return rawFetch(url, init);
|
||||
};
|
||||
}
|
||||
|
||||
// WebCrypto, bridged across the vm2 realm boundary.
|
||||
//
|
||||
// Handing the raw outer-realm `globalThis.crypto` to the sandbox does NOT work: WebCrypto
|
||||
// validates its arguments with `instanceof` checks against the OUTER realm's typed arrays,
|
||||
// so a `Uint8Array` constructed inside the sandbox is rejected ("The data argument must be
|
||||
// an integer-type TypedArray"), and `crypto.subtle.digest(...)` silently resolves to an empty
|
||||
// digest instead of throwing. `crypto.randomUUID()` happens to take no argument and works.
|
||||
//
|
||||
// So we wrap the object and copy arguments into outer-realm typed arrays before delegating.
|
||||
// Result arrays are copied back into the sandbox realm so callers get working typed arrays.
|
||||
function bridgeCrypto() {
|
||||
if (!globalThis.crypto) return undefined;
|
||||
|
||||
const toOuter = (value) => {
|
||||
if (value instanceof Uint8Array) return new Uint8Array(value);
|
||||
if (value instanceof Uint16Array) return new Uint16Array(value);
|
||||
if (value instanceof Uint32Array) return new Uint32Array(value);
|
||||
if (value instanceof Int8Array) return new Int8Array(value);
|
||||
if (value instanceof Int16Array) return new Int16Array(value);
|
||||
if (value instanceof Int32Array) return new Int32Array(value);
|
||||
if (value instanceof Float32Array) return new Float32Array(value);
|
||||
if (value instanceof Float64Array) return new Float64Array(value);
|
||||
if (value instanceof BigInt64Array) return new BigInt64Array(value);
|
||||
if (value instanceof BigUint64Array) return new BigUint64Array(value);
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
return value;
|
||||
};
|
||||
|
||||
const toInner = (buf) => {
|
||||
try {
|
||||
return new Uint8Array(buf);
|
||||
} catch {
|
||||
return buf;
|
||||
}
|
||||
};
|
||||
|
||||
const bridged = {
|
||||
randomUUID: () => globalThis.crypto.randomUUID(),
|
||||
getRandomValues: (arr) => globalThis.crypto.getRandomValues(toOuter(arr)),
|
||||
};
|
||||
|
||||
if (globalThis.crypto.subtle) {
|
||||
bridged.subtle = {
|
||||
digest: (algo, data) =>
|
||||
globalThis.crypto.subtle.digest(algo, toOuter(data)).then(toInner),
|
||||
importKey: (...args) => globalThis.crypto.subtle.importKey(...args),
|
||||
exportKey: (format, key, algo) =>
|
||||
globalThis.crypto.subtle.exportKey(format, key, algo).then(toInner),
|
||||
generateKey: (...args) => globalThis.crypto.subtle.generateKey(...args),
|
||||
sign: (...args) => globalThis.crypto.subtle.sign(...args).then(toInner),
|
||||
verify: (...args) => globalThis.crypto.subtle.verify(...args),
|
||||
encrypt: (...args) => globalThis.crypto.subtle.encrypt(...args).then(toInner),
|
||||
decrypt: (...args) => globalThis.crypto.subtle.decrypt(...args).then(toInner),
|
||||
deriveBits: (...args) => globalThis.crypto.subtle.deriveBits(...args).then(toInner),
|
||||
deriveKey: (...args) => globalThis.crypto.subtle.deriveKey(...args),
|
||||
};
|
||||
}
|
||||
|
||||
return bridged;
|
||||
}
|
||||
|
||||
const bridgedCrypto = bridgeCrypto();
|
||||
// `sharp` writes files DIRECTLY via libvips, bypassing Node's `fs`. Injecting the raw library
|
||||
// therefore lets a snippet write anywhere the server process can — a relative path lands in
|
||||
// the server's CWD and an absolute/`../` path escapes the VFS entirely. We wrap the two file
|
||||
// writing entry points (`toFile` and `toFile` on the constructor, plus `toBuffer` passthrough)
|
||||
// so output paths are resolved through the VFS containment rules.
|
||||
//
|
||||
// `toFile` keeps sharp's real signature/return value; only the path is redirected.
|
||||
function bridgeSharp(vfs) {
|
||||
const resolveOut = (p) => {
|
||||
if (typeof p !== 'string' && !(p && typeof p.toString === 'function')) {
|
||||
throw new TypeError('sharp.toFile(): output path must be a string');
|
||||
}
|
||||
const diskPath = vfs.resolveWritePath(String(p));
|
||||
|
||||
// libvips creates missing parent directories itself, so `toFile('a/b/c.png')` would
|
||||
// silently materialise the whole `a/b/` tree. Node's `fs.writeFileSync` instead fails
|
||||
// with ENOENT. Enforce the fs semantics here so both write paths behave identically and
|
||||
// a typo'd path reports an error instead of quietly succeeding.
|
||||
const dir = path.dirname(diskPath);
|
||||
if (!fsModule.existsSync(dir)) {
|
||||
const err = new Error(
|
||||
`ENOENT: no such file or directory, open '${String(p)}'`
|
||||
);
|
||||
err.code = 'ENOENT';
|
||||
err.path = String(p);
|
||||
err.syscall = 'open';
|
||||
throw err;
|
||||
}
|
||||
|
||||
return diskPath;
|
||||
};
|
||||
|
||||
// Patch `toFile` on sharp's PROTOTYPE, not on individual instances.
|
||||
//
|
||||
// Snippets chain (`sharp(...).png().resize(...).toFile(...)`) and every chained call
|
||||
// returns a NEW instance, so patching one instance only catches a direct
|
||||
// `sharp(...).toFile(...)`. Patching the shared prototype catches every derived instance.
|
||||
const proto = sharp.prototype;
|
||||
if (proto && typeof proto.toFile === 'function' && !proto.toFile.__vfsPatched) {
|
||||
const originalToFile = proto.toFile;
|
||||
const patched = function toFile(file, ...rest) {
|
||||
return originalToFile.call(this, resolveOut(file), ...rest);
|
||||
};
|
||||
patched.__vfsPatched = true;
|
||||
proto.toFile = patched;
|
||||
}
|
||||
|
||||
// Factory passthrough preserving statics + prototype.
|
||||
const wrapped = function sharpFactory(...args) {
|
||||
return sharp(...args);
|
||||
};
|
||||
|
||||
// Preserve statics (sharp.cache, sharp.concurrency, sharp.simd, ...) and prototype.
|
||||
Object.setPrototypeOf(wrapped, sharp);
|
||||
for (const key of Object.keys(sharp)) {
|
||||
try { wrapped[key] = sharp[key]; } catch { /* read-only static */ }
|
||||
}
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
const NETWORK_GLOBALS = {
|
||||
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
|
||||
// WebCrypto globals (bridged across the vm2 realm boundary — see bridgeCrypto above).
|
||||
// Node's `crypto` MODULE is deliberately not exposed.
|
||||
...(bridgedCrypto ? { crypto: bridgedCrypto } : {}),
|
||||
Crypto: globalThis.Crypto,
|
||||
// Deterministic helpers occasionally needed alongside crypto.
|
||||
performance: globalThis.performance,
|
||||
Headers: globalThis.Headers,
|
||||
Request: globalThis.Request,
|
||||
Response: globalThis.Response,
|
||||
FormData: globalThis.FormData,
|
||||
AbortController: globalThis.AbortController,
|
||||
AbortSignal: globalThis.AbortSignal,
|
||||
URL: globalThis.URL,
|
||||
URLSearchParams: globalThis.URLSearchParams,
|
||||
Blob: globalThis.Blob,
|
||||
File: globalThis.File,
|
||||
TextEncoder: globalThis.TextEncoder,
|
||||
TextDecoder: globalThis.TextDecoder,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
|
||||
const lines = [];
|
||||
const push = (prefix) => (...args) => {
|
||||
let text;
|
||||
try {
|
||||
text = args
|
||||
.map((a) => {
|
||||
if (typeof a === 'string') return a;
|
||||
if (Buffer.isBuffer(a) || ArrayBuffer.isView(a) || (a && a.constructor && a.constructor.name === 'Buffer')) {
|
||||
return `<Buffer ${(a.byteLength ?? a.length ?? 0)} bytes>`;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(a, (k, v) => {
|
||||
if (v && typeof v === 'object') {
|
||||
if (v.type === 'Buffer' && Array.isArray(v.data)) {
|
||||
return `<Buffer ${v.data.length} bytes>`;
|
||||
}
|
||||
if (Buffer.isBuffer(v) || ArrayBuffer.isView(v) || (v.constructor && v.constructor.name === 'Buffer')) {
|
||||
return `<Buffer ${(v.byteLength ?? v.length ?? 0)} bytes>`;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
});
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(' ');
|
||||
} catch {
|
||||
text = String(args);
|
||||
}
|
||||
lines.push(prefix + text);
|
||||
};
|
||||
const sandboxConsole = {
|
||||
log: push(''),
|
||||
info: push(''),
|
||||
debug: push(''),
|
||||
warn: push('warn: '),
|
||||
error: push('error: '),
|
||||
trace: push('trace: '),
|
||||
};
|
||||
|
||||
const MAX_ITEMS = 200;
|
||||
|
||||
function sanitize(value, depth = 0) {
|
||||
if (value === null) return null;
|
||||
const t = typeof value;
|
||||
if (t === 'undefined') return { type: 'undefined' };
|
||||
if (t === 'boolean' || t === 'string') return value;
|
||||
if (t === 'number') return Number.isFinite(value) ? value : String(value);
|
||||
if (t === 'bigint') return `${value.toString()}n`;
|
||||
if (t === 'symbol') return String(value);
|
||||
if (t === 'function') return { type: 'function' };
|
||||
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value) || (value && value.constructor && value.constructor.name === 'Buffer')) {
|
||||
const len = value.byteLength ?? value.length ?? 0;
|
||||
return `<Buffer ${len} bytes>`;
|
||||
}
|
||||
if (depth >= 6) return { type: t, note: 'too deeply nested to serialize' };
|
||||
if (Array.isArray(value)) {
|
||||
const arr = new Array();
|
||||
const len = Math.min(typeof value.length === 'number' ? value.length : 0, MAX_ITEMS);
|
||||
for (let i = 0; i < len; i++) {
|
||||
try { arr[i] = sanitize(value[i], depth + 1); }
|
||||
catch { arr[i] = { type: 'unreadable' }; }
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
if (t === 'object') {
|
||||
let keys;
|
||||
try { keys = Object.keys(value); } catch { return { type: 'object', note: 'not serializable' }; }
|
||||
const out = {};
|
||||
for (const k of keys.slice(0, MAX_ITEMS)) {
|
||||
try { out[k] = sanitize(value[k], depth + 1); }
|
||||
catch { out[k] = { type: 'unreadable' }; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return { type: t };
|
||||
}
|
||||
|
||||
// Every genuine sandbox report is tagged with `__sandboxReport: true`.
|
||||
//
|
||||
// This is REQUIRED, not cosmetic: when the server runs under `node --watch` (see the `dev`
|
||||
// script), Node installs a module-loader hook that posts `watch:import` notices over the SAME
|
||||
// parentPort channel the worker reports on. Without this tag the parent cannot tell a real
|
||||
// report apart from loader chatter and will resolve on the wrong message.
|
||||
function report(payload) {
|
||||
const message = { __sandboxReport: true, ...payload };
|
||||
try {
|
||||
parentPort.postMessage(message);
|
||||
} catch {
|
||||
try {
|
||||
parentPort.postMessage({
|
||||
__sandboxReport: true,
|
||||
result: null,
|
||||
error: 'Sandbox produced a result that could not be serialized.',
|
||||
errorName: 'SerializationError',
|
||||
stdout: lines.join('\n'),
|
||||
consoleLineCount: lines.length,
|
||||
});
|
||||
} catch { /* thread exiting */ }
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const vfs = createVfs(vfsDir, { googleAuthId });
|
||||
|
||||
const vm = new NodeVM({
|
||||
console: 'off',
|
||||
sandbox: {
|
||||
console: sandboxConsole,
|
||||
fs: vfs,
|
||||
vfs,
|
||||
sharp: bridgeSharp(vfs),
|
||||
validateLabelObjects: (objects) => validateObjects(JSON.parse(JSON.stringify(objects))),
|
||||
validateLabelObject: (object) => validateObject(JSON.parse(JSON.stringify(object))),
|
||||
...NETWORK_GLOBALS,
|
||||
},
|
||||
require: {
|
||||
root,
|
||||
external: false,
|
||||
builtin: [],
|
||||
},
|
||||
nesting: false,
|
||||
bufferAllocLimit: bufferLimit,
|
||||
timeout: vmTimeout,
|
||||
});
|
||||
|
||||
const started = Date.now();
|
||||
const done = () => ({
|
||||
stdout: lines.join('\n'),
|
||||
consoleLineCount: lines.length,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
|
||||
let value;
|
||||
let failed = null;
|
||||
try {
|
||||
value = vm.run(code, 'javascript_execute.js');
|
||||
if (value && typeof value.then === 'function') {
|
||||
const settleTimeout = Math.max(1000, vmTimeout - 500);
|
||||
value = await Promise.race([
|
||||
value,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), settleTimeout)
|
||||
),
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
const rawMsg = (err && err.message) || String(err);
|
||||
failed = {
|
||||
error: rawMsg,
|
||||
errorName: (err && err.name) || 'Error',
|
||||
cause: err && err.cause ? ((err.cause && err.cause.message) || String(err.cause)) : undefined,
|
||||
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
|
||||
};
|
||||
// Snippets run in a CommonJS wrapper, so bare top-level `await` is a syntax error.
|
||||
// That is the single most common mistake, so say exactly how to fix it.
|
||||
if (err instanceof SyntaxError && /await is only valid|Unexpected reserved word/i.test(rawMsg)) {
|
||||
failed.error =
|
||||
'Top-level `await` is not allowed: snippets run in a CommonJS (non-ESM) wrapper. ' +
|
||||
'Wrap async code in an async IIFE and assign the promise to module.exports:\n' +
|
||||
' module.exports = (async () => { const res = await fetch("/company"); return res.json(); })();';
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error('[javascript-worker] snippet threw:', failed.error, '\n', failed.stack || '(no stack)');
|
||||
report({ result: null, ...done(), ...failed });
|
||||
} else {
|
||||
// `result` MUST always be present: if the key were absent, the parent's
|
||||
// JSON.stringify() drops it and the tool response degenerates to a bare
|
||||
// echo of the submitted source with no sign of execution.
|
||||
report({ result: sanitize(value), ...done() });
|
||||
}
|
||||
} catch (err) {
|
||||
// Setup-level failure (bad VFS dir, thrown import, etc.) — log it here too, because the
|
||||
// parent only sees a message and the worker's stderr is otherwise swallowed.
|
||||
console.error('[javascript-worker] fatal setup error:', (err && err.message) || String(err), '\n', (err && err.stack) || '(no stack)');
|
||||
report({
|
||||
result: null,
|
||||
stdout: lines.join('\n'),
|
||||
consoleLineCount: lines.length,
|
||||
error: (err && err.message) || String(err),
|
||||
errorName: (err && err.name) || 'Error',
|
||||
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
|
||||
});
|
||||
}
|
||||
})();
|
||||
240
src/tools/javascript.js
Normal file
240
src/tools/javascript.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Worker } from 'worker_threads';
|
||||
import { fileURLToPath } from 'url';
|
||||
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';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, '../..');
|
||||
const WORKER_PATH = fileURLToPath(new URL('./javascript-worker.js', import.meta.url));
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
const MAX_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_MAX_OUTPUT = 20_000;
|
||||
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
|
||||
|
||||
// Keys already logged as benign non-sandbox worker chatter. Under `node --watch` the loader
|
||||
// emits `watch:import` / `watch:require` notices on the worker's parentPort for EVERY import,
|
||||
// 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 }) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||
|
||||
const worker = new Worker(WORKER_PATH, {
|
||||
workerData: {
|
||||
code,
|
||||
root: ROOT_DIR,
|
||||
vmTimeout: timeoutMs,
|
||||
bufferLimit: DEFAULT_BUFFER_LIMIT,
|
||||
dashbackUrl: `http://127.0.0.1:${env.port}/api`,
|
||||
dashbackToken: token,
|
||||
vfsDir: vfsRoot,
|
||||
googleAuthId,
|
||||
},
|
||||
});
|
||||
|
||||
const finish = (payload) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try { worker.terminate(); } catch { /* already closed */ }
|
||||
resolve(payload);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
console.error(`[javascript_execute] sandbox worker timed out after ${timeoutMs} ms`);
|
||||
finish({ result: null, stdout: '', consoleLineCount: 0, timedOut: true, timeoutMs });
|
||||
}, timeoutMs);
|
||||
|
||||
worker.on('message', (msg) => {
|
||||
// Ignore anything that is not a genuine sandbox report. Running the server under
|
||||
// `node --watch` (the `dev` script) installs a module-loader hook that posts
|
||||
// `watch:import` notices on this same channel; treating the first message as the
|
||||
// result made the sandbox appear to run for 0ms and return nothing.
|
||||
if (!msg || msg.__sandboxReport !== true) {
|
||||
// Those loader notices fire many times per run, so warn ONCE per key rather
|
||||
// than on every message — expected, benign noise while running under --watch.
|
||||
for (const key of Object.keys(msg || {})) {
|
||||
if (!WATCH_NOISE_WARNED.has(key)) {
|
||||
WATCH_NOISE_WARNED.add(key);
|
||||
console.warn(
|
||||
`[javascript_execute] ignoring non-sandbox worker message "${key}" ` +
|
||||
'(expected under `node --watch`; logged once per key).'
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
finish(msg);
|
||||
});
|
||||
// A worker 'error' fires BEFORE any message when the thread fails to boot (e.g. an
|
||||
// import throws at startup). ALWAYS send a truthy `error` plus the full stack so the
|
||||
// parent can throw with something actionable; an empty `err.message` must never
|
||||
// degrade into a silent, result-less success payload.
|
||||
worker.on('error', (err) => finish({
|
||||
result: null,
|
||||
stdout: '',
|
||||
consoleLineCount: 0,
|
||||
error: (err && err.message)
|
||||
? err.message
|
||||
: `Sandbox worker failed to start: ${err?.stack || err?.name || 'unknown error'}`,
|
||||
errorName: (err && err.name) || 'Error',
|
||||
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
|
||||
}));
|
||||
worker.on('exit', (code) => {
|
||||
// `finish` is idempotent via `settled`, so a message that already resolved the
|
||||
// promise is never overwritten. Reaching here means the worker died WITHOUT
|
||||
// reporting — that is a hard failure, so log it and always carry a truthy error.
|
||||
if (!settled) {
|
||||
console.error(`[javascript_execute] sandbox worker exited without reporting (code ${code})`);
|
||||
}
|
||||
finish({
|
||||
result: null,
|
||||
stdout: '',
|
||||
consoleLineCount: 0,
|
||||
error: `Sandbox worker exited unexpectedly without reporting a result (code ${code}).`,
|
||||
errorName: 'WorkerExit',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatSandboxError(code, report) {
|
||||
const errName = report.errorName || 'Error';
|
||||
const msg = report.error || 'Unknown error';
|
||||
const lines = [`${errName}: ${msg}`];
|
||||
if (report.stdout) {
|
||||
lines.push('Console output before crash:\n' + report.stdout);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the javascript_execute tool bound to the authenticated user and their company.
|
||||
* @param {{ googleAuthId: number, companyId: number }} ctx
|
||||
*/
|
||||
export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
|
||||
return defineTool({
|
||||
name: 'javascript_execute',
|
||||
description:
|
||||
'Run JavaScript in a sandboxed Node environment and return the result. ' +
|
||||
'Runs in an isolated worker thread with a hard wall-clock timeout. ' +
|
||||
'Return a value by assigning `module.exports` (sync value or a promise). ' +
|
||||
'For async code: `module.exports = (async () => { ... })()`. ' +
|
||||
'console.* is captured and returned as stdout. ' +
|
||||
'To query the Dashback REST API from JavaScript: call `await fetch("/route")` (e.g. `const res = await fetch("/article/2"); const data = await res.json();`). ' +
|
||||
'Relative paths automatically resolve against Dashback and are authenticated with your user credentials. ' +
|
||||
'A virtual filesystem (fs / vfs) is available to store and retrieve artifacts (files, images, JSON data, buffers) across runs (e.g. `fs.writeFileSync("artifact.json", data)`). ' +
|
||||
'IMAGES WRITTEN TO THE VFS ARE DISPLAYED INLINE IN THE CHAT. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)` or `sharp(...).toFile("images/example.png")`), ' +
|
||||
'reference the exact path you wrote with standard markdown `` and it renders as an image inline. ' +
|
||||
'Note the `image/` folder is reserved for database images and is read-only; write generated images anywhere else. ' +
|
||||
'Always embed images you generate this way so the user sees them. Other file types do not render inline. ' +
|
||||
'The `sharp` image library is a pre-injected GLOBAL. ' +
|
||||
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor. ' +
|
||||
'CRYPTO: only the WebCrypto global `crypto` is available. Node\'s `crypto` module is NOT installed, so ' +
|
||||
'`require("crypto")` will fail. Available: `crypto.randomUUID()`, `crypto.getRandomValues(typedArray)`, ' +
|
||||
'and the async `crypto.subtle` API (digest/hash, sign/verify, encrypt/decrypt, importKey/exportKey, deriveBits). ' +
|
||||
'`crypto.subtle` is async, so use it inside an async IIFE: ' +
|
||||
'`module.exports = (async () => (await crypto.subtle.digest("SHA-256", data)).byteLength)();`. ' +
|
||||
'Typed arrays work correctly across the sandbox boundary (verified against Node\'s real digests).',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description:
|
||||
'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
|
||||
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`. ' +
|
||||
'To query Dashback REST API: `const res = await fetch("/article/2"); const data = await res.json();`. ' +
|
||||
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`. ' +
|
||||
'Bare top-level `await` is a syntax error here — always wrap it in that async IIFE. ' +
|
||||
'Globals available: `fetch`, `console`, `fs`/`vfs`, `sharp`, `performance`, `URL`, `URLSearchParams`, `Headers`, ' +
|
||||
'`Request`, `Response`, `FormData`, `Blob`, `File`, `TextEncoder`, `TextDecoder`, `AbortController`, ' +
|
||||
'`AbortSignal`, `setTimeout`, `clearTimeout`, and WebCrypto `crypto` (`randomUUID`, `getRandomValues`, `subtle`). ' +
|
||||
'No Node built-ins are installable — `require("crypto")`, `require("fs")`, `require("path")` etc. all fail.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: `Hard wall-clock limit in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
|
||||
},
|
||||
max_output: {
|
||||
type: 'number',
|
||||
description: `Max chars of captured console output to return. Default ${DEFAULT_MAX_OUTPUT}.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT }) => {
|
||||
if (typeof code !== 'string' || !code.trim()) {
|
||||
throw new ToolError('"code" must be a non-empty string.');
|
||||
}
|
||||
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({ companyId, googleAuthId });
|
||||
let report;
|
||||
try {
|
||||
report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
|
||||
} finally {
|
||||
await syncVfsToDb({ googleAuthId, companyId, vfsRoot });
|
||||
}
|
||||
|
||||
if (report.timedOut) {
|
||||
throw new ToolError(
|
||||
`Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`
|
||||
);
|
||||
}
|
||||
|
||||
if (report.error) {
|
||||
throw new ToolError(formatSandboxError(code, report));
|
||||
}
|
||||
|
||||
// The worker ALWAYS reports a `result` key (javascript-worker.js). Its absence
|
||||
// means the worker died before reporting — that is a hard failure, not a result.
|
||||
// Throwing (rather than returning a success-shaped object) is deliberate: a
|
||||
// fabricated `{ ok:false, result:null }` reads to the model as "ran, returned
|
||||
// nothing", which is indistinguishable from a snippet that genuinely returned
|
||||
// undefined. Throw so the failure is unambiguous and gets logged.
|
||||
if (report === null || typeof report !== 'object' || !('result' in report)) {
|
||||
const keys = report && typeof report === 'object' ? Object.keys(report) : [];
|
||||
console.error(
|
||||
'[javascript_execute] sandbox worker produced no result payload',
|
||||
{ keys, report }
|
||||
);
|
||||
throw new ToolError(
|
||||
'Sandbox worker did not report a result payload. ' +
|
||||
`Report keys: [${keys.join(', ') || 'none'}]. This is a harness failure, ` +
|
||||
'not an empty result — check the server logs for the worker error above.'
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = report.stdout ?? '';
|
||||
let truncated = false;
|
||||
if (stdout.length > maxOutput) {
|
||||
stdout = stdout.slice(0, maxOutput);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
result: report.result,
|
||||
// Always present, even when empty, so callers can rely on the shape.
|
||||
stdout,
|
||||
};
|
||||
if (truncated) {
|
||||
result.truncated = true;
|
||||
result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`;
|
||||
}
|
||||
result.consoleLines = report.consoleLineCount ?? 0;
|
||||
result.durationMs = report.durationMs ?? 0;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
39
src/tools/reset-vfs.js
Normal file
39
src/tools/reset-vfs.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool } from 'libllmorbiter';
|
||||
import { getStore } from '../db/store.js';
|
||||
|
||||
/**
|
||||
* Creates the reset_vfs tool to wipe artifacts stored in the virtual filesystem.
|
||||
* @param {{ googleAuthId: number, companyId: number }} ctx
|
||||
*/
|
||||
export function createResetVfsTool({ googleAuthId, companyId }) {
|
||||
return defineTool({
|
||||
name: 'reset_vfs',
|
||||
description: 'Delete all files, images, and artifacts stored in the virtual filesystem (VFS) for this company.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
invoke: async () => {
|
||||
const store = await getStore();
|
||||
const res = await store.modelvfs.reset(googleAuthId);
|
||||
|
||||
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
|
||||
if (fs.existsSync(vfsRoot)) {
|
||||
try {
|
||||
const entries = fs.readdirSync(vfsRoot);
|
||||
for (const e of entries) {
|
||||
fs.rmSync(path.join(vfsRoot, e), { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[reset_vfs] local wipe warning:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, deletedCount: res?.deletedCount || 0 };
|
||||
},
|
||||
});
|
||||
}
|
||||
318
src/tools/vfs.js
Normal file
318
src/tools/vfs.js
Normal file
@@ -0,0 +1,318 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getStore } from '../db/store.js';
|
||||
import env from '../config/env.js';
|
||||
|
||||
const FULL_HASH_REGEX = /^[a-fA-F0-9]{64}$/;
|
||||
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
|
||||
|
||||
function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.json': return 'application/json';
|
||||
case '.avif': return 'image/avif';
|
||||
case '.webp': return 'image/webp';
|
||||
case '.png': return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.txt': return 'text/plain';
|
||||
case '.csv': return 'text/csv';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
export function extractFullHash(p) {
|
||||
if (typeof p !== 'string' || !p.trim()) return null;
|
||||
const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
const parts = clean.split('/');
|
||||
const last = parts[parts.length - 1];
|
||||
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
|
||||
if (FULL_HASH_REGEX.test(withoutExt)) {
|
||||
return withoutExt.toLowerCase();
|
||||
}
|
||||
for (const part of parts) {
|
||||
const seg = part.replace(/\.[a-zA-Z0-9]+$/, '');
|
||||
if (FULL_HASH_REGEX.test(seg)) {
|
||||
return seg.toLowerCase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isReadOnlyDatabasePath(filePath) {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) return false;
|
||||
const clean = filePath.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
const lower = clean.toLowerCase();
|
||||
if (lower === 'image' || lower.startsWith('image/')) return true;
|
||||
if (extractFullHash(clean)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function assertNotReadOnlyDatabasePath(filePath) {
|
||||
if (isReadOnlyDatabasePath(filePath)) {
|
||||
throw new Error(
|
||||
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the local directory for this company's VFS exists and is hydrated from modelvfs.
|
||||
* @param {{ companyId: number, googleAuthId: number }} ctx
|
||||
* @returns {Promise<string>} vfsRoot directory path
|
||||
*/
|
||||
export async function prepareVfs({ companyId, googleAuthId }) {
|
||||
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
const store = await getStore();
|
||||
const files = await store.modelvfs.list(googleAuthId);
|
||||
for (const file of files) {
|
||||
const diskPath = path.resolve(vfsRoot, file.name);
|
||||
if (!diskPath.startsWith(vfsRoot)) continue;
|
||||
if (!fs.existsSync(diskPath)) {
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
const fullRecord = await store.modelvfs.read(googleAuthId, file.name);
|
||||
if (fullRecord?.content) {
|
||||
fs.writeFileSync(diskPath, fullRecord.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[vfs] hydration warning:', err.message);
|
||||
}
|
||||
|
||||
return vfsRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table.
|
||||
* @param {{ companyId: number, googleAuthId: number, vfsRoot: string }} ctx
|
||||
*/
|
||||
export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
|
||||
if (!fs.existsSync(vfsRoot)) return;
|
||||
const store = await getStore();
|
||||
|
||||
function walk(dir, rel = '') {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const list = [];
|
||||
for (const e of entries) {
|
||||
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
||||
const fullPath = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
list.push(...walk(fullPath, relPath));
|
||||
} else if (e.isFile()) {
|
||||
list.push({ relPath, fullPath });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
const currentFiles = walk(vfsRoot);
|
||||
for (const f of currentFiles) {
|
||||
if (isReadOnlyDatabasePath(f.relPath)) continue;
|
||||
try {
|
||||
const content = fs.readFileSync(f.fullPath);
|
||||
const mime = getMimeType(f.relPath);
|
||||
await store.modelvfs.write(googleAuthId, {
|
||||
name: f.relPath,
|
||||
mime,
|
||||
content,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to sync file ${f.relPath} to db:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sandboxed VFS module object mirroring Node's `fs` and `fs/promises`.
|
||||
* @param {string} vfsRoot
|
||||
* @param {{ googleAuthId: number, companyId: number }} [options]
|
||||
*/
|
||||
export function createVfs(vfsRoot, options = {}) {
|
||||
const { googleAuthId } = options;
|
||||
|
||||
// Node fs errors embed the REAL absolute on-disk path, which leaks the server's directory
|
||||
// layout into the model-visible transcript. Rewrite them to use a stable `vfs://` prefix.
|
||||
const sanitizeFsError = (err, virtualPath) => {
|
||||
if (!err || typeof err.message !== 'string') return err;
|
||||
const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath);
|
||||
const message = err.message
|
||||
.split(vfsRoot)
|
||||
.join('vfs://')
|
||||
.split(path.sep)
|
||||
.join('/')
|
||||
.replace(/vfs:\/\/\/+/g, 'vfs:///');
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(err)), err, {
|
||||
message,
|
||||
path: virtual,
|
||||
});
|
||||
};
|
||||
|
||||
const safeResolve = (p) => {
|
||||
if (typeof p !== 'string' || !p.trim()) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
}
|
||||
const cleanVirtual = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
const resolved = path.resolve(vfsRoot, cleanVirtual);
|
||||
const relative = path.relative(vfsRoot, resolved);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const syncFetchImage = (hash) => {
|
||||
if (!hash) return null;
|
||||
try {
|
||||
// Read from storage or cache if available
|
||||
const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`);
|
||||
if (fs.existsSync(cachePath)) return fs.readFileSync(cachePath);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const vfs = {
|
||||
readFileSync: (p, opt) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) {
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? buf.toString(encoding) : buf;
|
||||
}
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.readFileSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
writeFileSync: (p, data, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
try {
|
||||
return fs.writeFileSync(diskPath, data, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
appendFileSync: (p, data, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
try {
|
||||
return fs.appendFileSync(diskPath, data, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
existsSync: (p) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) return true;
|
||||
}
|
||||
try {
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.existsSync(diskPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
statSync: (p, opt) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) {
|
||||
const now = new Date();
|
||||
return {
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
size: buf.length,
|
||||
mtime: now,
|
||||
ctime: now,
|
||||
atime: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.statSync(diskPath, opt);
|
||||
},
|
||||
|
||||
lstatSync: (p, opt) => vfs.statSync(p, opt),
|
||||
|
||||
readdirSync: (p = '.', opt) => {
|
||||
const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : '';
|
||||
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
|
||||
return [];
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.readdirSync(diskPath, opt);
|
||||
},
|
||||
|
||||
mkdirSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.mkdirSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
unlinkSync: (p) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.unlinkSync(diskPath);
|
||||
},
|
||||
|
||||
rmdirSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmdirSync(diskPath, opt);
|
||||
},
|
||||
|
||||
rmSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmSync(diskPath, opt);
|
||||
},
|
||||
|
||||
promises: {
|
||||
readFile: async (p, opt) => vfs.readFileSync(p, opt),
|
||||
writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
|
||||
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
|
||||
stat: async (p, opt) => vfs.statSync(p, opt),
|
||||
readdir: async (p, opt) => vfs.readdirSync(p, opt),
|
||||
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
|
||||
unlink: async (p) => vfs.unlinkSync(p),
|
||||
rm: async (p, opt) => vfs.rmSync(p, opt),
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a virtual path to an absolute on-disk path for writing, applying the SAME
|
||||
* containment + read-only guards as `writeFileSync`. Exposed so non-`fs` writers that
|
||||
* bypass the VFS (e.g. `sharp(...).toFile()`) can be funnelled back inside the sandbox
|
||||
* boundary instead of writing wherever the process CWD happens to be.
|
||||
*
|
||||
* Deliberately does NOT create parent directories: callers such as `sharp().toFile()`
|
||||
* must behave like `fs.writeFileSync`, which errors with ENOENT when the parent is
|
||||
* missing. Auto-creating the whole tree turns a typo'd path into silent success.
|
||||
*/
|
||||
resolveWritePath: (p) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
return safeResolve(p);
|
||||
},
|
||||
};
|
||||
|
||||
return vfs;
|
||||
}
|
||||
Reference in New Issue
Block a user