u
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List label templates
|
||||
|
||||
Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. `content.objects` is omitted to reduce payload size.
|
||||
Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. The `content` field is omitted to reduce payload size (use GET /labeltemplate/{id} for it).
|
||||
|
||||
## Response `200`
|
||||
|
||||
|
||||
39
docs/api/routes/modelconversation.get.md
Normal file
39
docs/api/routes/modelconversation.get.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# `GET /modelconversation`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List conversations
|
||||
|
||||
Lists the authenticated user's model conversations in the selected company, most recently updated first.
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of conversations
|
||||
|
||||
```json
|
||||
{
|
||||
"conversations": [
|
||||
{
|
||||
"id": 1,
|
||||
"companyuser_id": 1,
|
||||
"provider": "…",
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | No company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
51
docs/api/routes/modelconversation.post.md
Normal file
51
docs/api/routes/modelconversation.post.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# `POST /modelconversation`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Create conversation
|
||||
|
||||
Creates a new conversation in state `idle`.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `provider` | yes | string | |
|
||||
| `model` | yes | string | |
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "…",
|
||||
"model": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Response `201`
|
||||
|
||||
Created conversation
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation": {
|
||||
"id": 1,
|
||||
"companyuser_id": 1,
|
||||
"provider": "…",
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid input or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
32
docs/api/routes/modelconversation/_id.delete.md
Normal file
32
docs/api/routes/modelconversation/_id.delete.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# `DELETE /modelconversation/{id}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Delete conversation
|
||||
|
||||
Deletes one of the authenticated user's model conversations and all its messages.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `204`
|
||||
|
||||
Conversation deleted
|
||||
|
||||
_No content_
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
44
docs/api/routes/modelconversation/_id.get.md
Normal file
44
docs/api/routes/modelconversation/_id.get.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# `GET /modelconversation/{id}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Get conversation
|
||||
|
||||
Gets details and current state of one of the authenticated user's model conversations.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Conversation details
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation": {
|
||||
"id": 1,
|
||||
"companyuser_id": 1,
|
||||
"provider": "…",
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
44
docs/api/routes/modelconversation/_id/message.get.md
Normal file
44
docs/api/routes/modelconversation/_id/message.get.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# `GET /modelconversation/{id}/message`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List conversation messages
|
||||
|
||||
Lists messages of one of the user's own conversations, oldest first.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of messages
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"id": 1,
|
||||
"conversation_id": 1,
|
||||
"created_at": "…",
|
||||
"role": "system",
|
||||
"message": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
65
docs/api/routes/modelconversation/_id/message.post.md
Normal file
65
docs/api/routes/modelconversation/_id/message.post.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# `POST /modelconversation/{id}/message`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Post a user message
|
||||
|
||||
Appends a message with role `user` to the conversation.
|
||||
Refused with 409 unless the conversation state is `idle`.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `message` | no | string | User message text or fallback content |
|
||||
| `role` | no | string | Message role ('user' or 'tool') |
|
||||
| `tool_call_id` | no | string | Target tool call ID when responding to a tool like ask_user |
|
||||
| `content` | no | | Tool response content payload |
|
||||
| `answer` | no | | Shorthand answer for ask_user response |
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "…",
|
||||
"role": "user",
|
||||
"tool_call_id": "…",
|
||||
"content": "…",
|
||||
"answer": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Response `201`
|
||||
|
||||
Created message
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"id": 1,
|
||||
"conversation_id": 1,
|
||||
"created_at": "…",
|
||||
"role": "system",
|
||||
"message": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID, empty message or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
| 409 | Conversation is not idle — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
37
docs/api/routes/modelconversation/_id/stream.md
Normal file
37
docs/api/routes/modelconversation/_id/stream.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# `GET /modelconversation/{id}/stream`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Stream conversation events (SSE)
|
||||
|
||||
Opens a Server-Sent Events stream for the specified conversation.
|
||||
Streams `message` events (persisted user/assistant messages), live `delta`
|
||||
text chunks of the model answer, and `state` events (`thinking`, `working`,
|
||||
`done`). The SSE states are not stored; the database only knows `idle`/`busy`.
|
||||
`done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or
|
||||
`error` (string) if the run failed.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Conversation ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
SSE stream (`text/event-stream`). Emits `state`, `delta`, and `message` events.
|
||||
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Conversation not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
62
docs/api/routes/public/_companyId/articles.md
Normal file
62
docs/api/routes/public/_companyId/articles.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# `GET /public/{companyId}/articles`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
List public articles
|
||||
|
||||
Returns a paginated list of catalog articles for the specified company, optionally filtered by category.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `companyId` | yes | integer | Company ID |
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `categoryId` | no | integer | Optional category ID to filter by |
|
||||
| `search` | no | string | Search query for name or SKU |
|
||||
| `limit` | no | integer | Number of articles to return (max 200) |
|
||||
| `offset` | no | integer | Number of articles to skip |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Articles list
|
||||
|
||||
```json
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"slug": "…",
|
||||
"net": 1,
|
||||
"taxRate": 1,
|
||||
"gross": 1,
|
||||
"trackStock": true,
|
||||
"brand": {
|
||||
"id": 1,
|
||||
"name": "…"
|
||||
},
|
||||
"fullHash": "…",
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid parameters — `{"error":"…"}` |
|
||||
| 404 | Company not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
64
docs/api/routes/public/_companyId/articles/_sku.md
Normal file
64
docs/api/routes/public/_companyId/articles/_sku.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# `GET /public/{companyId}/articles/{sku}`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
Get public article detail
|
||||
|
||||
Returns detailed public catalog data for a single article identified by SKU.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `companyId` | yes | integer | Company ID |
|
||||
| `sku` | yes | string | Article SKU |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Article detail
|
||||
|
||||
```json
|
||||
{
|
||||
"article": {
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"slug": "…",
|
||||
"description": "…",
|
||||
"unitamount": 1,
|
||||
"weight": 1,
|
||||
"trackStock": true,
|
||||
"unit": {},
|
||||
"brand": {},
|
||||
"price": {},
|
||||
"prices": [
|
||||
{}
|
||||
],
|
||||
"categories": [
|
||||
{}
|
||||
],
|
||||
"photos": [
|
||||
{}
|
||||
],
|
||||
"eans": [
|
||||
"…"
|
||||
],
|
||||
"isFather": true,
|
||||
"fatherSku": "…",
|
||||
"variants": [
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid parameters — `{"error":"…"}` |
|
||||
| 404 | Article or Company not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
46
docs/api/routes/public/_companyId/categories.md
Normal file
46
docs/api/routes/public/_companyId/categories.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# `GET /public/{companyId}/categories`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
List public categories
|
||||
|
||||
Returns the category tree for the specified company.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `companyId` | yes | integer | Company ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Category list
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"id": 1,
|
||||
"parentId": 1,
|
||||
"sort": 1,
|
||||
"name": "…",
|
||||
"slug": "…",
|
||||
"fullHash": "…",
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid parameters — `{"error":"…"}` |
|
||||
| 404 | Company not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -28,7 +28,8 @@
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.5",
|
||||
"swagger-ui-dist": "^5.33.1"
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"vm2": "^3.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
@@ -936,6 +937,30 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -4108,6 +4133,22 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vm2": {
|
||||
"version": "3.12.2",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.12.2.tgz",
|
||||
"integrity": "sha512-LRHt4xU6eXshlZEcSZVrTehLGxRDjHvjmFPGwEp4xGpZX61QzH6Xm9wK2nU7XAp36V1w2xILAL1LwxVkgD1Ipw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-walk": "^8.3.4"
|
||||
},
|
||||
"bin": {
|
||||
"vm2": "bin/vm2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.5",
|
||||
"swagger-ui-dist": "^5.33.1"
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"vm2": "^3.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
|
||||
@@ -41,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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import env from '../config/env.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { ensureConnection } from '../db/store.js';
|
||||
import { closeAll as closeAllSse } from './sse.js';
|
||||
import { closeAllManagers } from '../services/conversationManager.js';
|
||||
import { getOrCreateCert } from '../util/cert.js';
|
||||
import v1Router from '../v1/index.js';
|
||||
|
||||
@@ -47,6 +48,16 @@ let startedAt;
|
||||
function corsMiddleware(req, res, next) {
|
||||
const origin = req.headers.origin;
|
||||
|
||||
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
if (origin && env.corsOrigins.includes(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
@@ -66,8 +77,8 @@ async function createApi() {
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
// Do not attach no-store to SSE endpoint
|
||||
if (req.path !== '/api/events') {
|
||||
// Do not attach no-store to SSE or public endpoints
|
||||
if (req.path !== '/api/events' && !req.path.startsWith('/api/public') && !req.path.startsWith('/public')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
next();
|
||||
@@ -89,14 +100,20 @@ async function createApi() {
|
||||
app.use('/v1', v1Router);
|
||||
app.use('/api/v1', v1Router);
|
||||
|
||||
app.use((err, _req, res, next) => {
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err.type === 'entity.too.large') {
|
||||
return res.status(413).json({ error: 'Payload too large' });
|
||||
}
|
||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||
return res.status(400).json({ error: 'Invalid JSON body' });
|
||||
}
|
||||
next(err);
|
||||
console.error('Unhandled API error:', err);
|
||||
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
||||
res.status(status).json({ error: err.message || 'Internal server error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
@@ -174,6 +191,7 @@ export async function stopApi() {
|
||||
// Long-lived SSE (and keep-alive) sockets otherwise block server.close()
|
||||
// forever — node --watch hangs on "Waiting for graceful termination...".
|
||||
closeAllSse();
|
||||
closeAllManagers();
|
||||
const closers = [];
|
||||
if (server) {
|
||||
const s = server;
|
||||
|
||||
@@ -55,6 +55,10 @@ export const authMiddleware = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/@') || req.path.startsWith('/node_modules') || req.path.startsWith('/src') || req.path === '/favicon.ico') {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -852,6 +852,66 @@
|
||||
* transaction_text:
|
||||
* type: string
|
||||
* description: Transaction memo / details
|
||||
* ModelConversation:
|
||||
* type: object
|
||||
* required: [id, companyuser_id, provider, model, created_at, updated_at, state]
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* companyuser_id:
|
||||
* type: integer
|
||||
* provider:
|
||||
* type: string
|
||||
* model:
|
||||
* type: string
|
||||
* created_at:
|
||||
* type: string
|
||||
* updated_at:
|
||||
* type: string
|
||||
* state:
|
||||
* type: string
|
||||
* enum: [idle, busy]
|
||||
* description: Messages can only be posted while `idle`
|
||||
* ModelConversationCreateInput:
|
||||
* type: object
|
||||
* required: [provider, model]
|
||||
* properties:
|
||||
* provider:
|
||||
* type: string
|
||||
* model:
|
||||
* type: string
|
||||
* ModelMessage:
|
||||
* type: object
|
||||
* required: [id, conversation_id, created_at, role, message]
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* conversation_id:
|
||||
* type: integer
|
||||
* created_at:
|
||||
* type: string
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [system, user, assistant, tool, reasoning]
|
||||
* message:
|
||||
* type: string
|
||||
* ModelMessageCreateInput:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* description: User message text or fallback content
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [user, tool]
|
||||
* description: Message role ('user' or 'tool')
|
||||
* tool_call_id:
|
||||
* type: string
|
||||
* description: Target tool call ID when responding to a tool like ask_user
|
||||
* content:
|
||||
* description: Tool response content payload
|
||||
* answer:
|
||||
* description: Shorthand answer for ask_user response
|
||||
* Printer:
|
||||
* type: object
|
||||
* required: [id, company_id, address, type, sort]
|
||||
|
||||
@@ -45,6 +45,7 @@ export const openApiOptions = {
|
||||
{ name: 'Stock', description: 'Manual stock additions and removals' },
|
||||
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
|
||||
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
|
||||
{ name: 'PublicCatalog', description: 'Public product catalog API' },
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
|
||||
@@ -70,7 +70,7 @@ function articleErrorStatus(error) {
|
||||
if (error.message === 'Unit not found') return 404;
|
||||
if (error.message === 'Invalid eans') return 400;
|
||||
if (error.message === 'EAN already in use' || error.message === 'EAN already assigned') return 409;
|
||||
if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint')) return 409;
|
||||
if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint') || error.message?.includes('already exists')) return 409;
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Brand not found') return 404;
|
||||
if (error.message === 'Invalid brandId') return 400;
|
||||
@@ -222,6 +222,7 @@ export function register(app, basePath = '') {
|
||||
return res.status(400).json({ error: 'Invalid track_stock' });
|
||||
}
|
||||
const track_stock = rawTrack !== undefined ? Boolean(rawTrack) : true;
|
||||
const customSku = typeof req.body?.sku === 'string' && req.body.sku.trim() ? req.body.sku.trim() : undefined;
|
||||
|
||||
try {
|
||||
const created = await db.createUnderParent({
|
||||
@@ -229,6 +230,7 @@ export function register(app, basePath = '') {
|
||||
parentId,
|
||||
name,
|
||||
track_stock,
|
||||
sku: customSku,
|
||||
});
|
||||
emitArticlesSignal(req);
|
||||
const article = {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function register(app, basePath = '') {
|
||||
* get:
|
||||
* tags: [LabelTemplate]
|
||||
* summary: List label templates
|
||||
* description: Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. `content.objects` is omitted to reduce payload size.
|
||||
* description: Lists all ZPL label templates for the selected company. The `thumb` field is returned as a base64-encoded PNG string when present. The `content` field is omitted to reduce payload size (use GET /labeltemplate/{id} for it).
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-markdown: labeltemplate/list.md
|
||||
@@ -83,7 +83,10 @@ export function register(app, basePath = '') {
|
||||
|
||||
try {
|
||||
const templates = await db.list(req.user.id);
|
||||
res.status(200).json({ labeltemplates: templates.map(serializeTemplate) });
|
||||
res.status(200).json({ labeltemplates: templates.map((t) => {
|
||||
const { content, ...rest } = serializeTemplate(t);
|
||||
return rest;
|
||||
}) });
|
||||
} catch (error) {
|
||||
const status = labeltemplateErrorStatus(error);
|
||||
if (status === 500) console.error('List label templates failed:', error);
|
||||
|
||||
577
src/api/routes/modelconversation.js
Normal file
577
src/api/routes/modelconversation.js
Normal file
@@ -0,0 +1,577 @@
|
||||
import * as db from '../../db/modelconversation.js';
|
||||
import { emit } from '../sse.js';
|
||||
import { getOrCreateManager, removeManager } from '../../services/conversationManager.js';
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return { error: 'No company selected' };
|
||||
}
|
||||
return { companyId };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, field) {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { error: `${field} is required` };
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
function modelConversationErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Conversation not found') return 404;
|
||||
if (error.message === 'Provider required') return 400;
|
||||
if (error.message === 'Model required') return 400;
|
||||
if (error.message === 'Message required') return 400;
|
||||
if (error.message === 'Conversation is not idle') return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers model conversation routes. Conversations are private to the
|
||||
* authenticated user within their selected company.
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: List conversations
|
||||
* description: Lists the authenticated user's model conversations in the selected company, most recently updated first.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of conversations
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* conversations:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/ModelConversation'
|
||||
* 400:
|
||||
* description: No company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const conversations = await db.list(req.user.id);
|
||||
res.status(200).json({ conversations });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('List model conversations failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation:
|
||||
* post:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Create conversation
|
||||
* description: Creates a new conversation in state `idle`.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ModelConversationCreateInput'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created conversation
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* conversation:
|
||||
* $ref: '#/components/schemas/ModelConversation'
|
||||
* 400:
|
||||
* description: Invalid input or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.post(`${basePath}/modelconversation`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const { provider, model } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const conversation = await db.create(req.user.id, { provider, model });
|
||||
emit('modelconversation', { companyId: selected.companyId });
|
||||
res.status(201).json({ conversation });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Create model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Get conversation
|
||||
* description: Gets details and current state of one of the authenticated user's model conversations.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Conversation details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* conversation:
|
||||
* $ref: '#/components/schemas/ModelConversation'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const conversation = await db.get(req.user.id, parsedId.id);
|
||||
res.status(200).json({ conversation });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Get model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}:
|
||||
* delete:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Delete conversation
|
||||
* description: Deletes one of the authenticated user's model conversations and all its messages.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Conversation deleted
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.delete(`${basePath}/modelconversation/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.remove(req.user.id, parsedId.id);
|
||||
removeManager(parsedId.id);
|
||||
emit('modelconversation', { companyId: selected.companyId });
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Delete model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}/stream:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Stream conversation events (SSE)
|
||||
* description: |
|
||||
* Opens a Server-Sent Events stream for the specified conversation.
|
||||
* Streams `message` events (persisted user/assistant messages), live `delta`
|
||||
* text chunks of the model answer, and `state` events (`thinking`, `working`,
|
||||
* `done`). The SSE states are not stored; the database only knows `idle`/`busy`.
|
||||
* `done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or
|
||||
* `error` (string) if the run failed.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: |
|
||||
* SSE stream (`text/event-stream`). Emits `state`, `delta`, and `message` events.
|
||||
* content:
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* example: |
|
||||
* event: state
|
||||
* data: {"state":"thinking"}
|
||||
*
|
||||
* event: state
|
||||
* data: {"state":"working"}
|
||||
*
|
||||
* event: delta
|
||||
* data: {"delta":"Hel","text":"Hel"}
|
||||
*
|
||||
* event: message
|
||||
* data: {"id":2,"conversation_id":1,"created_at":"...","role":"assistant","message":"Hello there"}
|
||||
*
|
||||
* event: state
|
||||
* data: {"state":"done","usage":{"input":20,"cached":0,"output":3,"cost":0.0001}}
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation/:id/stream`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure conversation exists and user has access
|
||||
await db.get(req.user.id, parsedId.id);
|
||||
const manager = getOrCreateManager(parsedId.id, {
|
||||
companyId: selected.companyId,
|
||||
googleAuthId: req.user.id,
|
||||
});
|
||||
manager.subscribe(res, req);
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Stream model conversation failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}/message:
|
||||
* get:
|
||||
* tags: [ModelConversation]
|
||||
* summary: List conversation messages
|
||||
* description: Lists messages of one of the user's own conversations, oldest first.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of messages
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/ModelMessage'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/modelconversation/:id/message`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await db.listMessages(req.user.id, parsedId.id);
|
||||
res.status(200).json({ messages });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('List model messages failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /modelconversation/{id}/message:
|
||||
* post:
|
||||
* tags: [ModelConversation]
|
||||
* summary: Post a user message
|
||||
* description: |
|
||||
* Appends a message with role `user` to the conversation.
|
||||
* Refused with 409 unless the conversation state is `idle`.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Conversation ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ModelMessageCreateInput'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created message
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* $ref: '#/components/schemas/ModelMessage'
|
||||
* 400:
|
||||
* description: Invalid ID, empty message or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Conversation not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 409:
|
||||
* description: Conversation is not idle
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.post(`${basePath}/modelconversation/:id/message`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
const { message, tool_call_id, role, content, answer } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const manager = getOrCreateManager(parsedId.id, {
|
||||
companyId: selected.companyId,
|
||||
googleAuthId: req.user.id,
|
||||
});
|
||||
|
||||
let created;
|
||||
if (role === 'tool' || tool_call_id) {
|
||||
if (!tool_call_id) {
|
||||
return res.status(400).json({ error: 'tool_call_id is required for tool response' });
|
||||
}
|
||||
created = await manager.handleToolMessage(req.user.id, {
|
||||
tool_call_id,
|
||||
content,
|
||||
answer: answer !== undefined ? answer : message,
|
||||
});
|
||||
} else {
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: 'Message required' });
|
||||
}
|
||||
created = await manager.handleUserMessage(req.user.id, { message });
|
||||
}
|
||||
|
||||
emit(`modelconversation-${parsedId.id}`, { companyId: selected.companyId });
|
||||
res.status(201).json({ message: created });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
if (status === 500) console.error('Post model message failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
364
src/api/routes/publicCatalog.js
Normal file
364
src/api/routes/publicCatalog.js
Normal file
@@ -0,0 +1,364 @@
|
||||
import * as db from '../../db/publicCatalog.js';
|
||||
|
||||
function parseCompanyId(id) {
|
||||
const companyId = Number(id);
|
||||
if (!Number.isInteger(companyId) || companyId <= 0) {
|
||||
return { error: 'Invalid company id' };
|
||||
}
|
||||
return { companyId };
|
||||
}
|
||||
|
||||
function parseSku(value) {
|
||||
const sku = typeof value === 'string' ? value.trim() : '';
|
||||
if (!sku) return { error: 'sku is required' };
|
||||
return { sku: decodeURIComponent(sku) };
|
||||
}
|
||||
|
||||
function publicErrorStatus(error) {
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Article not found' || error.message?.startsWith('Article not found')) return 404;
|
||||
if (error.message === 'Category not found') return 404;
|
||||
if (error.message?.includes('required') || error.message?.includes('Invalid')) return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers public catalog routes (no session/cookie auth required).
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /public/{companyId}/categories:
|
||||
* get:
|
||||
* tags: [PublicCatalog]
|
||||
* summary: List public categories
|
||||
* description: Returns the category tree for the specified company.
|
||||
* security: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: companyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Company ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Category list
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* categories:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* parentId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* sort:
|
||||
* type: integer
|
||||
* name:
|
||||
* type: string
|
||||
* slug:
|
||||
* type: string
|
||||
* fullHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* thumbW:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbH:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Company not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/public/:companyId/categories`, async (req, res) => {
|
||||
const parsed = parseCompanyId(req.params.companyId);
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const categories = await db.listCategories(parsed.companyId);
|
||||
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300');
|
||||
res.status(200).json({ categories });
|
||||
} catch (error) {
|
||||
const status = publicErrorStatus(error);
|
||||
if (status === 500) console.error('Public listCategories failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /public/{companyId}/articles:
|
||||
* get:
|
||||
* tags: [PublicCatalog]
|
||||
* summary: List public articles
|
||||
* description: Returns a paginated list of catalog articles for the specified company, optionally filtered by category.
|
||||
* security: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: companyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Company ID
|
||||
* - in: query
|
||||
* name: categoryId
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Optional category ID to filter by
|
||||
* - in: query
|
||||
* name: search
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Search query for name or SKU
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* description: Number of articles to return (max 200)
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* description: Number of articles to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Articles list
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* articles:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* sku:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* slug:
|
||||
* type: string
|
||||
* net:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* taxRate:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* gross:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* trackStock:
|
||||
* type: boolean
|
||||
* brand:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* name:
|
||||
* type: string
|
||||
* fullHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* thumbW:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbH:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* thumbHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Company not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/public/:companyId/articles`, async (req, res) => {
|
||||
const parsed = parseCompanyId(req.params.companyId);
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
const categoryId = req.query.categoryId != null ? Number(req.query.categoryId) : undefined;
|
||||
if (categoryId !== undefined && (!Number.isInteger(categoryId) || categoryId <= 0)) {
|
||||
return res.status(400).json({ error: 'Invalid categoryId' });
|
||||
}
|
||||
|
||||
const limit = req.query.limit != null ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset != null ? Number(req.query.offset) : undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
|
||||
|
||||
try {
|
||||
const articles = await db.listArticles(parsed.companyId, {
|
||||
categoryId,
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300');
|
||||
res.status(200).json({ articles });
|
||||
} catch (error) {
|
||||
const status = publicErrorStatus(error);
|
||||
if (status === 500) console.error('Public listArticles failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /public/{companyId}/articles/{sku}:
|
||||
* get:
|
||||
* tags: [PublicCatalog]
|
||||
* summary: Get public article detail
|
||||
* description: Returns detailed public catalog data for a single article identified by SKU.
|
||||
* security: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: companyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Company ID
|
||||
* - in: path
|
||||
* name: sku
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Article SKU
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Article detail
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* article:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* sku:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* slug:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* unitamount:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* weight:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* trackStock:
|
||||
* type: boolean
|
||||
* unit:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* brand:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* price:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* prices:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* categories:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* photos:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* eans:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* isFather:
|
||||
* type: boolean
|
||||
* fatherSku:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* variants:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Article or Company not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/public/:companyId/articles/:sku`, async (req, res) => {
|
||||
const parsedCompany = parseCompanyId(req.params.companyId);
|
||||
if (parsedCompany.error) {
|
||||
return res.status(400).json({ error: parsedCompany.error });
|
||||
}
|
||||
|
||||
const parsedSku = parseSku(req.params.sku);
|
||||
if (parsedSku.error) {
|
||||
return res.status(400).json({ error: parsedSku.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const article = await db.getArticleBySku(parsedCompany.companyId, parsedSku.sku);
|
||||
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300');
|
||||
res.status(200).json({ article });
|
||||
} catch (error) {
|
||||
const status = publicErrorStatus(error);
|
||||
if (status === 500) console.error('Public getArticleBySku failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function createUnderParent({ googleAuthId, parentId, name, track_stock }) {
|
||||
export async function createUnderParent({ googleAuthId, parentId, name, track_stock, sku }) {
|
||||
const store = await getStore();
|
||||
return store.article.createUnderParent({ googleAuthId, parentId, name, track_stock });
|
||||
return store.article.createUnderParent({ googleAuthId, parentId, name, track_stock, sku });
|
||||
}
|
||||
|
||||
export async function getBySku(googleAuthId, sku) {
|
||||
|
||||
36
src/db/modelconversation.js
Normal file
36
src/db/modelconversation.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function list(googleAuthId) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.list(googleAuthId);
|
||||
}
|
||||
|
||||
export async function create(googleAuthId, data) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.create(googleAuthId, data);
|
||||
}
|
||||
|
||||
export async function get(googleAuthId, conversationId) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.get(googleAuthId, conversationId);
|
||||
}
|
||||
|
||||
export async function setState(googleAuthId, conversationId, state) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.setState(googleAuthId, conversationId, state);
|
||||
}
|
||||
|
||||
export async function listMessages(googleAuthId, conversationId) {
|
||||
const store = await getStore();
|
||||
return store.modelmessage.list(googleAuthId, conversationId);
|
||||
}
|
||||
|
||||
export async function addMessage(googleAuthId, conversationId, data) {
|
||||
const store = await getStore();
|
||||
return store.modelmessage.add(googleAuthId, conversationId, data);
|
||||
}
|
||||
|
||||
export async function remove(googleAuthId, conversationId) {
|
||||
const store = await getStore();
|
||||
return store.modelconversation.delete(googleAuthId, conversationId);
|
||||
}
|
||||
26
src/db/modelvfs.js
Normal file
26
src/db/modelvfs.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function write(googleAuthId, input) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.write(googleAuthId, input);
|
||||
}
|
||||
|
||||
export async function read(googleAuthId, name) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.read(googleAuthId, name);
|
||||
}
|
||||
|
||||
export async function list(googleAuthId) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.list(googleAuthId);
|
||||
}
|
||||
|
||||
export async function remove(googleAuthId, name) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.delete(googleAuthId, name);
|
||||
}
|
||||
|
||||
export async function reset(googleAuthId) {
|
||||
const store = await getStore();
|
||||
return store.modelvfs.reset(googleAuthId);
|
||||
}
|
||||
498
src/db/publicCatalog.js
Normal file
498
src/db/publicCatalog.js
Normal file
@@ -0,0 +1,498 @@
|
||||
import { getProvider } from './store.js';
|
||||
|
||||
export async function getCompanyCatalogMeta(companyId) {
|
||||
const provider = await getProvider();
|
||||
const result = await provider.query(
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
root_category_id AS rootCategoryId,
|
||||
default_language_id AS languageId,
|
||||
poscustomergroup_id AS posCustomerGroupId
|
||||
FROM company
|
||||
WHERE id = @companyId`,
|
||||
{ companyId }
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listCategories(companyId) {
|
||||
const company = await getCompanyCatalogMeta(companyId);
|
||||
if (!company) {
|
||||
throw new Error('Company not found');
|
||||
}
|
||||
if (!company.rootCategoryId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
const isSqlite = provider.dialect === 'sqlite3';
|
||||
|
||||
const sql = isSqlite
|
||||
? `WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_id, sort
|
||||
FROM category
|
||||
WHERE id = @rootId
|
||||
UNION ALL
|
||||
SELECT c.id, c.parent_id, c.sort
|
||||
FROM category c
|
||||
INNER JOIN tree t ON c.parent_id = t.id
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_id AS parentId,
|
||||
t.sort,
|
||||
ct.name,
|
||||
ct.slug,
|
||||
p.hash AS fullHash,
|
||||
p.thumbw AS thumbW,
|
||||
p.thumbh AS thumbH,
|
||||
p.thumbhash AS thumbHash
|
||||
FROM tree t
|
||||
LEFT JOIN categorytext ct
|
||||
ON ct.category_id = t.id AND ct.language_id = @languageId
|
||||
LEFT JOIN pic p ON p.id = (
|
||||
SELECT cp.pic_id
|
||||
FROM categorypic cp
|
||||
WHERE cp.category_id = t.id
|
||||
ORDER BY cp.sort, cp.id
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY t.sort, t.id`
|
||||
: `WITH tree AS (
|
||||
SELECT id, parent_id, sort
|
||||
FROM category
|
||||
WHERE id = @rootId
|
||||
UNION ALL
|
||||
SELECT c.id, c.parent_id, c.sort
|
||||
FROM category c
|
||||
INNER JOIN tree t ON c.parent_id = t.id
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_id AS parentId,
|
||||
t.sort,
|
||||
ct.name,
|
||||
ct.slug,
|
||||
p.hash AS fullHash,
|
||||
p.thumbw AS thumbW,
|
||||
p.thumbh AS thumbH,
|
||||
p.thumbhash AS thumbHash
|
||||
FROM tree t
|
||||
LEFT JOIN categorytext ct
|
||||
ON ct.category_id = t.id AND ct.language_id = @languageId
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 cp.pic_id
|
||||
FROM categorypic cp
|
||||
WHERE cp.category_id = t.id
|
||||
ORDER BY cp.sort ASC, cp.id ASC
|
||||
) first_pic
|
||||
LEFT JOIN pic p ON p.id = first_pic.pic_id
|
||||
ORDER BY t.sort, t.id`;
|
||||
|
||||
const result = await provider.query(sql, {
|
||||
rootId: company.rootCategoryId,
|
||||
languageId: company.languageId,
|
||||
});
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
parentId: row.parentId,
|
||||
sort: row.sort,
|
||||
name: row.name ?? '',
|
||||
slug: row.slug ?? '',
|
||||
fullHash: row.fullHash ?? null,
|
||||
thumbW: row.thumbW ?? null,
|
||||
thumbH: row.thumbH ?? null,
|
||||
thumbHash: row.thumbHash ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listArticles(companyId, { categoryId, limit = 50, offset = 0, search } = {}) {
|
||||
const company = await getCompanyCatalogMeta(companyId);
|
||||
if (!company) {
|
||||
throw new Error('Company not found');
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
const isSqlite = provider.dialect === 'sqlite3';
|
||||
|
||||
const safeLimit = Math.min(Math.max(1, Number(limit) || 50), 200);
|
||||
const safeOffset = Math.max(0, Number(offset) || 0);
|
||||
const trimmedSearch = typeof search === 'string' ? search.trim() : '';
|
||||
|
||||
const params = {
|
||||
companyId,
|
||||
languageId: company.languageId,
|
||||
posCustomerGroupId: company.posCustomerGroupId,
|
||||
limit: safeLimit,
|
||||
offset: safeOffset,
|
||||
};
|
||||
|
||||
if (categoryId != null) {
|
||||
params.categoryId = Number(categoryId);
|
||||
}
|
||||
if (trimmedSearch) {
|
||||
params.searchPattern = `%${trimmedSearch}%`;
|
||||
}
|
||||
|
||||
const whereClauses = [
|
||||
'a.company_id = @companyId',
|
||||
'a.fatherarticle_id IS NULL',
|
||||
];
|
||||
if (categoryId != null) {
|
||||
whereClauses.push('ca.category_id = @categoryId');
|
||||
}
|
||||
if (trimmedSearch) {
|
||||
whereClauses.push('(a.sku LIKE @searchPattern OR at.name LIKE @searchPattern)');
|
||||
}
|
||||
const whereSql = whereClauses.join(' AND ');
|
||||
|
||||
const fromClause = categoryId != null
|
||||
? 'categoryarticle ca INNER JOIN article a ON a.id = ca.article_id'
|
||||
: 'article a';
|
||||
|
||||
const sql = isSqlite
|
||||
? `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
pr.net,
|
||||
t.rate AS taxRate,
|
||||
a.track_stock AS trackStock,
|
||||
pic.hash AS fullHash,
|
||||
pic.thumbw AS thumbW,
|
||||
pic.thumbh AS thumbH,
|
||||
pic.thumbhash AS thumbHash,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName
|
||||
FROM ${fromClause}
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN price pr
|
||||
ON pr.article_id = a.id
|
||||
AND pr.customergroup_id = @posCustomerGroupId
|
||||
AND pr.minmultiplier = 1
|
||||
LEFT JOIN tax t ON t.id = pr.tax_id
|
||||
LEFT JOIN pic ON pic.id = (
|
||||
SELECT ap.pic_id
|
||||
FROM articlepic ap
|
||||
WHERE ap.article_id = a.id
|
||||
ORDER BY ap.sort, ap.id
|
||||
LIMIT 1
|
||||
)
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY a.id
|
||||
LIMIT @limit OFFSET @offset`
|
||||
: `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
pr.net,
|
||||
t.rate AS taxRate,
|
||||
a.track_stock AS trackStock,
|
||||
pic.hash AS fullHash,
|
||||
pic.thumbw AS thumbW,
|
||||
pic.thumbh AS thumbH,
|
||||
pic.thumbhash AS thumbHash,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName
|
||||
FROM ${fromClause}
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN price pr
|
||||
ON pr.article_id = a.id
|
||||
AND pr.customergroup_id = @posCustomerGroupId
|
||||
AND pr.minmultiplier = 1
|
||||
LEFT JOIN tax t ON t.id = pr.tax_id
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 ap.pic_id
|
||||
FROM articlepic ap
|
||||
WHERE ap.article_id = a.id
|
||||
ORDER BY ap.sort ASC, ap.id ASC
|
||||
) first_pic
|
||||
LEFT JOIN pic ON pic.id = first_pic.pic_id
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY a.id
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`;
|
||||
|
||||
const result = await provider.query(sql, params);
|
||||
|
||||
return result.rows.map((row) => {
|
||||
const net = row.net != null ? Number(row.net) : null;
|
||||
const taxRate = row.taxRate != null ? Number(row.taxRate) : null;
|
||||
const gross = net != null && taxRate != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
sku: row.sku,
|
||||
name: row.name ?? '',
|
||||
slug: row.slug ?? '',
|
||||
net,
|
||||
taxRate,
|
||||
gross,
|
||||
trackStock: Boolean(row.trackStock ?? 1),
|
||||
brand: row.brandName
|
||||
? {
|
||||
id: row.brandId ?? null,
|
||||
name: row.brandName,
|
||||
}
|
||||
: null,
|
||||
fullHash: row.fullHash ?? null,
|
||||
thumbW: row.thumbW ?? null,
|
||||
thumbH: row.thumbH ?? null,
|
||||
thumbHash: row.thumbHash ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getArticleBySku(companyId, sku) {
|
||||
const company = await getCompanyCatalogMeta(companyId);
|
||||
if (!company) {
|
||||
throw new Error('Company not found');
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
const isSqlite = provider.dialect === 'sqlite3';
|
||||
|
||||
const sql = isSqlite
|
||||
? `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
at.desc AS description,
|
||||
a.unitamount,
|
||||
a.weight,
|
||||
a.track_stock AS trackStock,
|
||||
u.si AS unitSi,
|
||||
u.basesi AS unitBasesi,
|
||||
u.basesifactor AS unitBasesifactor,
|
||||
ut.symbol AS unitSymbol,
|
||||
ut.name AS unitName,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName,
|
||||
brand_pic.hash AS brandFullHash,
|
||||
brand_pic.thumbw AS brandThumbW,
|
||||
brand_pic.thumbh AS brandThumbH,
|
||||
brand_pic.thumbhash AS brandThumbHash,
|
||||
a.is_father AS isFather,
|
||||
a.fatherarticle_id AS fatherArticleId,
|
||||
father.sku AS fatherSku
|
||||
FROM article a
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN unit u ON u.id = a.unit_id
|
||||
LEFT JOIN unittext ut
|
||||
ON ut.unit_id = u.id AND ut.language_id = @languageId
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
LEFT JOIN pic brand_pic ON brand_pic.id = (
|
||||
SELECT bp.pic_id
|
||||
FROM brandpic bp
|
||||
WHERE bp.brand_id = b.id
|
||||
ORDER BY bp.sort, bp.id
|
||||
LIMIT 1
|
||||
)
|
||||
LEFT JOIN article father ON father.id = a.fatherarticle_id
|
||||
WHERE a.sku = @sku AND a.company_id = @companyId`
|
||||
: `SELECT
|
||||
a.id,
|
||||
a.sku,
|
||||
at.name,
|
||||
at.slug,
|
||||
at.desc AS description,
|
||||
a.unitamount,
|
||||
a.weight,
|
||||
a.track_stock AS trackStock,
|
||||
u.si AS unitSi,
|
||||
u.basesi AS unitBasesi,
|
||||
u.basesifactor AS unitBasesifactor,
|
||||
ut.symbol AS unitSymbol,
|
||||
ut.name AS unitName,
|
||||
b.id AS brandId,
|
||||
b.name AS brandName,
|
||||
brand_pic.hash AS brandFullHash,
|
||||
brand_pic.thumbw AS brandThumbW,
|
||||
brand_pic.thumbh AS brandThumbH,
|
||||
brand_pic.thumbhash AS brandThumbHash,
|
||||
a.is_father AS isFather,
|
||||
a.fatherarticle_id AS fatherArticleId,
|
||||
father.sku AS fatherSku
|
||||
FROM article a
|
||||
LEFT JOIN articletext at
|
||||
ON at.article_id = a.id AND at.language_id = @languageId
|
||||
LEFT JOIN unit u ON u.id = a.unit_id
|
||||
LEFT JOIN unittext ut
|
||||
ON ut.unit_id = u.id AND ut.language_id = @languageId
|
||||
LEFT JOIN brand b ON b.id = a.brand_id
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 bp.pic_id
|
||||
FROM brandpic bp
|
||||
WHERE bp.brand_id = b.id
|
||||
ORDER BY bp.sort ASC, bp.id ASC
|
||||
) first_brand_pic
|
||||
LEFT JOIN pic brand_pic ON brand_pic.id = first_brand_pic.pic_id
|
||||
LEFT JOIN article father ON father.id = a.fatherarticle_id
|
||||
WHERE a.sku = @sku AND a.company_id = @companyId`;
|
||||
|
||||
const result = await provider.query(sql, {
|
||||
sku,
|
||||
companyId,
|
||||
languageId: company.languageId,
|
||||
});
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('Article not found');
|
||||
}
|
||||
|
||||
const articleId = row.id;
|
||||
|
||||
const [categoriesRes, photosRes, eansRes, pricesRes, variantsRes] = await Promise.all([
|
||||
provider.query(
|
||||
`SELECT ca.category_id AS categoryId, ct.name, ct.slug
|
||||
FROM categoryarticle ca
|
||||
LEFT JOIN categorytext ct ON ct.category_id = ca.category_id AND ct.language_id = @languageId
|
||||
WHERE ca.article_id = @articleId
|
||||
ORDER BY ca.category_id`,
|
||||
{ articleId, languageId: company.languageId }
|
||||
),
|
||||
provider.query(
|
||||
`SELECT
|
||||
ap.sort,
|
||||
p.hash AS fullHash,
|
||||
p.w AS fullW,
|
||||
p.h AS fullH,
|
||||
p.size AS fullSize,
|
||||
p.thumbw AS thumbW,
|
||||
p.thumbh AS thumbH,
|
||||
p.thumbhash AS thumbHash
|
||||
FROM articlepic ap
|
||||
INNER JOIN pic p ON p.id = ap.pic_id
|
||||
WHERE ap.article_id = @articleId
|
||||
ORDER BY ap.sort ASC, ap.id ASC`,
|
||||
{ articleId }
|
||||
),
|
||||
provider.query(
|
||||
`SELECT ae.ean
|
||||
FROM articleean ae
|
||||
WHERE ae.article_id = @articleId
|
||||
ORDER BY ae.ean`,
|
||||
{ articleId }
|
||||
),
|
||||
provider.query(
|
||||
`SELECT
|
||||
pr.minmultiplier,
|
||||
pr.net,
|
||||
t.rate AS taxRate
|
||||
FROM price pr
|
||||
INNER JOIN tax t ON t.id = pr.tax_id
|
||||
WHERE pr.article_id = @articleId
|
||||
AND pr.customergroup_id = @posCustomerGroupId
|
||||
ORDER BY pr.minmultiplier ASC`,
|
||||
{ articleId, posCustomerGroupId: company.posCustomerGroupId }
|
||||
),
|
||||
row.isFather
|
||||
? provider.query(
|
||||
`SELECT
|
||||
child.id,
|
||||
child.sku,
|
||||
child.fathersort,
|
||||
child.track_stock AS trackStock,
|
||||
child.unitamount,
|
||||
child.weight,
|
||||
at_child.name,
|
||||
at_child.slug,
|
||||
av.id AS attributeValueId,
|
||||
av.value AS attributeValue
|
||||
FROM article child
|
||||
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = @languageId
|
||||
LEFT JOIN articleattributevalue aav ON aav.article_id = child.id
|
||||
LEFT JOIN attributevalue av ON av.id = aav.attributevalue_id
|
||||
WHERE child.fatherarticle_id = @articleId
|
||||
ORDER BY child.fathersort ASC, child.id ASC`,
|
||||
{ articleId, languageId: company.languageId }
|
||||
)
|
||||
: Promise.resolve({ rows: [] }),
|
||||
]);
|
||||
|
||||
const prices = pricesRes.rows.map((p) => {
|
||||
const net = p.net != null ? Number(p.net) : null;
|
||||
const taxRate = p.taxRate != null ? Number(p.taxRate) : null;
|
||||
const gross = net != null && taxRate != null ? +(net * (1 + taxRate / 100)).toFixed(2) : null;
|
||||
return {
|
||||
minmultiplier: p.minmultiplier,
|
||||
net,
|
||||
taxRate,
|
||||
gross,
|
||||
};
|
||||
});
|
||||
|
||||
const primaryPrice = prices[0] ?? null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
sku: row.sku,
|
||||
name: row.name ?? '',
|
||||
slug: row.slug ?? '',
|
||||
description: row.description ?? '',
|
||||
unitamount: row.unitamount,
|
||||
weight: row.weight,
|
||||
trackStock: Boolean(row.trackStock ?? 1),
|
||||
unit: row.unitSi
|
||||
? {
|
||||
si: row.unitSi,
|
||||
basesi: row.unitBasesi,
|
||||
basesifactor: row.unitBasesifactor,
|
||||
symbol: row.unitSymbol ?? null,
|
||||
name: row.unitName ?? null,
|
||||
}
|
||||
: null,
|
||||
brand: row.brandName
|
||||
? {
|
||||
id: row.brandId ?? null,
|
||||
name: row.brandName,
|
||||
fullHash: row.brandFullHash ?? null,
|
||||
thumbW: row.brandThumbW ?? null,
|
||||
thumbH: row.brandThumbH ?? null,
|
||||
thumbHash: row.brandThumbHash ?? null,
|
||||
}
|
||||
: null,
|
||||
price: primaryPrice,
|
||||
prices,
|
||||
categories: categoriesRes.rows.map((c) => ({
|
||||
id: c.categoryId,
|
||||
name: c.name ?? '',
|
||||
slug: c.slug ?? '',
|
||||
})),
|
||||
photos: photosRes.rows.map((p) => ({
|
||||
sort: p.sort,
|
||||
fullHash: p.fullHash,
|
||||
fullW: p.fullW,
|
||||
fullH: p.fullH,
|
||||
fullSize: p.fullSize,
|
||||
thumbW: p.thumbW,
|
||||
thumbH: p.thumbH,
|
||||
thumbHash: p.thumbHash,
|
||||
})),
|
||||
eans: eansRes.rows.map((e) => e.ean),
|
||||
isFather: Boolean(row.isFather),
|
||||
fatherSku: row.fatherSku ?? null,
|
||||
variants: variantsRes.rows.map((v) => ({
|
||||
id: v.id,
|
||||
sku: v.sku,
|
||||
sort: v.fathersort,
|
||||
name: v.name ?? '',
|
||||
slug: v.slug ?? '',
|
||||
attributeValueId: v.attributeValueId ?? null,
|
||||
attributeValue: v.attributeValue ?? null,
|
||||
unitamount: v.unitamount,
|
||||
weight: v.weight,
|
||||
trackStock: Boolean(v.trackStock ?? 1),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,8 @@ const STORE_NAMESPACES = [
|
||||
'attributevalue',
|
||||
'articleattributevalue',
|
||||
'categoryattributevalue',
|
||||
'modelconversation',
|
||||
'modelmessage',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -148,6 +150,11 @@ export async function getStore() {
|
||||
return connecting;
|
||||
}
|
||||
|
||||
export async function getProvider() {
|
||||
await getStore();
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function ensureConnection() {
|
||||
await getStore();
|
||||
}
|
||||
@@ -159,3 +166,4 @@ export async function closeStore() {
|
||||
store = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
521
src/services/conversationManager.js
Normal file
521
src/services/conversationManager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const FETCH_TIMEOUT_MS = 15000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
const MAX_EDGE = 1000;
|
||||
const TRIM_THRESHOLD = 10;
|
||||
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
|
||||
const AVIF_QUALITY = 80;
|
||||
const AVIF_EFFORT = 4;
|
||||
|
||||
@@ -79,7 +80,10 @@ export async function crawlImage(url, { signal } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims transparent and white borders from an image buffer, resizes to max 1000px, and converts to AVIF.
|
||||
* Trims transparent and white borders from an image buffer, restores up to
|
||||
* RESTORE_MARGIN px of the original image on each edge (never more than was
|
||||
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
|
||||
* and converts to AVIF.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Raw image buffer
|
||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||
@@ -88,56 +92,62 @@ export async function cropAndConvertToAvif(inputBuffer) {
|
||||
// 1. Initial metadata and orientation correction
|
||||
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
|
||||
const initialMeta = await baseSharp.metadata();
|
||||
const hasAlpha = Boolean(initialMeta.hasAlpha);
|
||||
|
||||
// Use PNG intermediate for trimming passes to preserve alpha
|
||||
let currentBuffer = await baseSharp.png().toBuffer();
|
||||
let currentMeta = initialMeta;
|
||||
const originalBuffer = await baseSharp.png().toBuffer();
|
||||
const origMeta = await sharp(originalBuffer).metadata();
|
||||
|
||||
// Cumulative crop box in original (oriented) pixel coordinates
|
||||
let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height };
|
||||
let currentBuffer = originalBuffer;
|
||||
|
||||
const trimPass = async (background) => {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
box = {
|
||||
left: box.left - (trimmed.info.trimOffsetLeft ?? 0),
|
||||
top: box.top - (trimmed.info.trimOffsetTop ?? 0),
|
||||
width: trimmed.info.width,
|
||||
height: trimmed.info.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Step A: Trim transparent borders if alpha is present
|
||||
if (currentMeta.hasAlpha) {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
}
|
||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
||||
|
||||
// 3. Step B: Trim white borders
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: '#ffffff', threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
await trimPass('#ffffff');
|
||||
|
||||
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
|
||||
if (currentMeta.hasAlpha) {
|
||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
||||
|
||||
// 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds
|
||||
const left = Math.max(0, box.left - RESTORE_MARGIN);
|
||||
const top = Math.max(0, box.top - RESTORE_MARGIN);
|
||||
const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN);
|
||||
const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN);
|
||||
if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
|
||||
const restored = await sharp(originalBuffer)
|
||||
.extract({ left, top, width: right - left, height: bottom - top })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
currentBuffer = restored.data;
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
}
|
||||
const currentMeta = await sharp(currentBuffer).metadata();
|
||||
|
||||
// 5. Resize to max 1000px without enlargement
|
||||
let finalPipeline = sharp(currentBuffer);
|
||||
|
||||
43
src/tools/ask-user.js
Normal file
43
src/tools/ask-user.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineTool, DirectReturnError } from 'libllmorbiter';
|
||||
|
||||
/**
|
||||
* ask_user tool:
|
||||
* Allows the LLM to ask the user for clarification or a decision (e.g. which SKU or ID
|
||||
* they meant, or what parameter to use).
|
||||
*
|
||||
* When invoked, it raises a DirectReturnError with recordResult: false so the LLMOrbiter
|
||||
* halts the inference loop immediately without generating a synthetic tool response.
|
||||
* The conversation transitions into `idle` state, allowing the client/user to submit
|
||||
* a tool response via the API.
|
||||
*/
|
||||
export function createAskUserTool() {
|
||||
return defineTool({
|
||||
name: 'ask_user',
|
||||
description:
|
||||
'Ask the user when you need clarification or a decision (e.g. which SKU or ID they meant, or what parameter to use). ' +
|
||||
'Give a concise question + up to 3 short options (A,B,C); the user may select an option or provide free-form text.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['question'],
|
||||
properties: {
|
||||
question: {
|
||||
type: 'string',
|
||||
description: 'Question for the user.',
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Up to 3 short option labels.',
|
||||
items: { type: 'string' },
|
||||
maxItems: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
invoke: async ({ question, options = [] }) => {
|
||||
const capped = Array.isArray(options) ? options.slice(0, 3) : [];
|
||||
// Stop LLM loop immediately so the conversation stays idle awaiting user tool response
|
||||
throw new DirectReturnError({ question, options: capped }, { recordResult: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
57
src/tools/dashback-request.js
Normal file
57
src/tools/dashback-request.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { defineTool } from 'libllmorbiter';
|
||||
import env from '../config/env.js';
|
||||
|
||||
const MAX_RESPONSE_CHARS = 20_000;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
|
||||
/**
|
||||
* Create an LLM tool that performs HTTP requests against this API, authenticated
|
||||
* as the given user (short-lived JWT, same auth path as browser requests).
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
*/
|
||||
export function createDashbackRequestTool({ googleAuthId }) {
|
||||
return defineTool({
|
||||
name: 'dashback_request',
|
||||
description:
|
||||
'Interrogate and call the Dashback REST API. Supports GET requests directly. ' +
|
||||
'Paths are relative to the API root (e.g. "/article/2", "/category/tree", "/brand").',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['method', 'path'],
|
||||
properties: {
|
||||
method: { type: 'string', enum: METHODS, description: 'HTTP method' },
|
||||
path: { type: 'string', description: 'Path of the endpoint to request (e.g. "/article/2", "/category/tree", "/brand").' },
|
||||
query: { type: 'object', description: 'Optional query parameters as key-value pairs (e.g. { limit: 10, offset: 0 }).' },
|
||||
body: { type: 'string', description: 'Raw JSON request payload string (e.g. \'{"weight": 2.5}\').' },
|
||||
},
|
||||
},
|
||||
invoke: async ({ method = 'GET', path, query = null, body = null }) => {
|
||||
if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`);
|
||||
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
|
||||
throw new Error('path must start with a single "/"');
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) };
|
||||
if (body != null && method !== 'GET' && method !== 'DELETE') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
const qs = query ? `?${new URLSearchParams(query).toString()}` : '';
|
||||
const url = `${path.startsWith('/docs') || path.startsWith('/api/') ? path : `/api${path}`}${qs}`;
|
||||
console.log(`[llmorbiter] Tool invoke dashback_request: ${method} ${url}`);
|
||||
const res = await fetch(`http://127.0.0.1:${env.port}${url}`, init);
|
||||
let text = await res.text();
|
||||
if (text.length > MAX_RESPONSE_CHARS) {
|
||||
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
|
||||
}
|
||||
return JSON.stringify({ status: res.status, body: text });
|
||||
},
|
||||
});
|
||||
}
|
||||
247
src/tools/javascript-worker.js
Normal file
247
src/tools/javascript-worker.js
Normal 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
157
src/tools/javascript.js
Normal 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
39
src/tools/reset-vfs.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool } from 'libllmorbiter';
|
||||
import { getStore } from '../db/store.js';
|
||||
|
||||
/**
|
||||
* Creates the reset_vfs tool to wipe artifacts stored in the virtual filesystem.
|
||||
* @param {{ googleAuthId: number, companyId: number }} ctx
|
||||
*/
|
||||
export function createResetVfsTool({ googleAuthId, companyId }) {
|
||||
return defineTool({
|
||||
name: 'reset_vfs',
|
||||
description: 'Delete all files, images, and artifacts stored in the virtual filesystem (VFS) for this company.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
invoke: async () => {
|
||||
const store = await getStore();
|
||||
const res = await store.modelvfs.reset(googleAuthId);
|
||||
|
||||
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
|
||||
if (fs.existsSync(vfsRoot)) {
|
||||
try {
|
||||
const entries = fs.readdirSync(vfsRoot);
|
||||
for (const e of entries) {
|
||||
fs.rmSync(path.join(vfsRoot, e), { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[reset_vfs] local wipe warning:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, deletedCount: res?.deletedCount || 0 };
|
||||
},
|
||||
});
|
||||
}
|
||||
272
src/tools/vfs.js
Normal file
272
src/tools/vfs.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user