This commit is contained in:
seb
2026-10-03 23:13:36 +02:00
parent 1786879c4e
commit 9a7f345d8b
39 changed files with 4611 additions and 50 deletions

View File

@@ -55,6 +55,14 @@ That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdo
| `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:

View File

@@ -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}` |

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 167 API endpoints across all modules.
Overview of all 177 API endpoints across all modules.
### Parameter Notation
@@ -124,6 +124,13 @@ Overview of all 167 API endpoints across all modules.
| `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 |
@@ -150,6 +157,9 @@ Overview of all 167 API endpoints across all modules.
| `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 |

View File

@@ -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`

View 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`._

View 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`._

View 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`._

View 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`._

View 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`._

View 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`._

View 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`._

View 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`._

View 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`._

View 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`._

43
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -41,6 +41,12 @@ 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}`);
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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]

View File

@@ -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: [

View File

@@ -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 = {

View File

@@ -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);

View 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 });
}
});
}

View 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 });
}
});
}

View File

@@ -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) {

View 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
View 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
View 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),
})),
};
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,521 @@
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.',
'- 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);
}
}

View File

@@ -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
View 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 });
},
});
}

View 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 });
},
});
}

View File

@@ -0,0 +1,247 @@
'use strict';
import { parentPort, workerData } from 'worker_threads';
import { NodeVM } from 'vm2';
import sharp from 'sharp';
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);
};
}
const NETWORK_GLOBALS = {
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
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 };
}
function report(payload) {
try {
parentPort.postMessage(payload);
} catch {
try {
parentPort.postMessage({ error: 'Sandbox produced a result that could not be serialized.' });
} catch { /* thread exiting */ }
}
}
void (async () => {
try {
const vfs = createVfs(vfsDir, { googleAuthId });
const vm = new NodeVM({
console: 'off',
sandbox: {
console: sandboxConsole,
fs: vfs,
vfs,
sharp,
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) {
failed = {
error: (err && err.message) || String(err),
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,
};
}
if (failed) {
report({ ...done(), ...failed });
} else {
report({ result: sanitize(value), ...done() });
}
} catch (err) {
report({
error: (err && err.message) || String(err),
errorName: (err && err.name) || 'Error',
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
});
}
})();

157
src/tools/javascript.js Normal file
View File

@@ -0,0 +1,157 @@
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;
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(() => {
finish({ timedOut: true, timeoutMs });
}, timeoutMs);
worker.on('message', (msg) => finish(msg));
worker.on('error', (err) => finish({ error: err.message, errorName: err.name }));
worker.on('exit', (code) => {
finish({
error: `Sandbox worker exited unexpectedly (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)`). ' +
'The `sharp` image library is a pre-injected GLOBAL. ' +
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor.',
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")`.',
},
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) {
return {
code,
timedOut: true,
timeoutMs: report.timeoutMs,
note: `Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`,
};
}
if (report.error) {
throw new ToolError(formatSandboxError(code, report));
}
let stdout = report.stdout ?? '';
let truncated = false;
if (stdout.length > maxOutput) {
stdout = stdout.slice(0, maxOutput);
truncated = true;
}
const result = { code, result: report.result };
if (stdout) result.stdout = stdout;
if (truncated) {
result.truncated = true;
result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`;
}
if (report.consoleLineCount !== undefined) result.consoleLines = report.consoleLineCount;
if (report.durationMs !== undefined) result.durationMs = report.durationMs;
return result;
},
});
}

39
src/tools/reset-vfs.js Normal file
View 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 };
},
});
}

272
src/tools/vfs.js Normal file
View File

@@ -0,0 +1,272 @@
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;
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);
return fs.readFileSync(diskPath, opt);
},
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.writeFileSync(diskPath, data, opt);
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.appendFileSync(diskPath, data, opt);
},
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);
return fs.mkdirSync(diskPath, opt);
},
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),
},
};
return vfs;
}