diff --git a/README.md b/README.md index 8ce848a..d2bbfbc 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/api/README.md b/docs/api/README.md index 43506ec..efe3b89 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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}` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index d7f1078..f0aede9 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -107,6 +107,10 @@ { "name": "Serper", "description": "Serper Google image search and thumbnail streaming" + }, + { + "name": "PublicCatalog", + "description": "Public product catalog API" } ], "paths": { @@ -2300,6 +2304,398 @@ } } }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, "/propertyname": { "get": { "tags": [ @@ -4530,6 +4926,552 @@ } } }, + "/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" + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/modelconversation/{id}/stream": { + "get": { + "tags": [ + "ModelConversation" + ], + "summary": "Stream conversation events (SSE)", + "description": "Opens a Server-Sent Events stream for the specified conversation.\nStreams `message` events (persisted user/assistant messages), live `delta`\ntext chunks of the model answer, and `state` events (`thinking`, `working`,\n`done`). The SSE states are not stored; the database only knows `idle`/`busy`.\n`done` carries `usage` (`input`, `cached`, `output`, `cost`) on success or\n`error` (string) if the run failed.\n", + "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.\n", + "content": { + "text/event-stream": { + "schema": { + "type": "string", + "example": "event: state\ndata: {\"state\":\"thinking\"}\n\nevent: state\ndata: {\"state\":\"working\"}\n\nevent: delta\ndata: {\"delta\":\"Hel\",\"text\":\"Hel\"}\n\nevent: message\ndata: {\"id\":2,\"conversation_id\":1,\"created_at\":\"...\",\"role\":\"assistant\",\"message\":\"Hello there\"}\n\nevent: state\ndata: {\"state\":\"done\",\"usage\":{\"input\":20,\"cached\":0,\"output\":3,\"cost\":0.0001}}\n" + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + }, + "post": { + "tags": [ + "ModelConversation" + ], + "summary": "Post a user message", + "description": "Appends a message with role `user` to the conversation.\nRefused with 409 unless the conversation state is `idle`.\n", + "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" + } + } + } + } + } + } + }, "/ledger": { "post": { "tags": [ @@ -4877,7 +5819,7 @@ "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": [] @@ -15722,6 +16664,122 @@ } } }, + "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": [ diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index d598220..073d5c6 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -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 | diff --git a/docs/api/routes/labeltemplate/list.md b/docs/api/routes/labeltemplate/list.md index 64a98bd..3e74cb7 100644 --- a/docs/api/routes/labeltemplate/list.md +++ b/docs/api/routes/labeltemplate/list.md @@ -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` diff --git a/docs/api/routes/modelconversation.get.md b/docs/api/routes/modelconversation.get.md new file mode 100644 index 0000000..0017921 --- /dev/null +++ b/docs/api/routes/modelconversation.get.md @@ -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`._ diff --git a/docs/api/routes/modelconversation.post.md b/docs/api/routes/modelconversation.post.md new file mode 100644 index 0000000..4a13e69 --- /dev/null +++ b/docs/api/routes/modelconversation.post.md @@ -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`._ diff --git a/docs/api/routes/modelconversation/_id.delete.md b/docs/api/routes/modelconversation/_id.delete.md new file mode 100644 index 0000000..9965d48 --- /dev/null +++ b/docs/api/routes/modelconversation/_id.delete.md @@ -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`._ diff --git a/docs/api/routes/modelconversation/_id.get.md b/docs/api/routes/modelconversation/_id.get.md new file mode 100644 index 0000000..88200fd --- /dev/null +++ b/docs/api/routes/modelconversation/_id.get.md @@ -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`._ diff --git a/docs/api/routes/modelconversation/_id/message.get.md b/docs/api/routes/modelconversation/_id/message.get.md new file mode 100644 index 0000000..7446c6c --- /dev/null +++ b/docs/api/routes/modelconversation/_id/message.get.md @@ -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`._ diff --git a/docs/api/routes/modelconversation/_id/message.post.md b/docs/api/routes/modelconversation/_id/message.post.md new file mode 100644 index 0000000..545400e --- /dev/null +++ b/docs/api/routes/modelconversation/_id/message.post.md @@ -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`._ diff --git a/docs/api/routes/modelconversation/_id/stream.md b/docs/api/routes/modelconversation/_id/stream.md new file mode 100644 index 0000000..e79e0e2 --- /dev/null +++ b/docs/api/routes/modelconversation/_id/stream.md @@ -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`._ diff --git a/docs/api/routes/public/_companyId/articles.md b/docs/api/routes/public/_companyId/articles.md new file mode 100644 index 0000000..5dd86d4 --- /dev/null +++ b/docs/api/routes/public/_companyId/articles.md @@ -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`._ diff --git a/docs/api/routes/public/_companyId/articles/_sku.md b/docs/api/routes/public/_companyId/articles/_sku.md new file mode 100644 index 0000000..3423cfd --- /dev/null +++ b/docs/api/routes/public/_companyId/articles/_sku.md @@ -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`._ diff --git a/docs/api/routes/public/_companyId/categories.md b/docs/api/routes/public/_companyId/categories.md new file mode 100644 index 0000000..4f35b67 --- /dev/null +++ b/docs/api/routes/public/_companyId/categories.md @@ -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`._ diff --git a/package-lock.json b/package-lock.json index 9894684..0891974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3fc9f51..7fb8570 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 6419df3..215ad3e 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -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}`); } diff --git a/src/api/index.js b/src/api/index.js index c5313f7..f9514db 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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; diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index 5fbdc8c..18fc957 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -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(); } diff --git a/src/api/openapi.js b/src/api/openapi.js index 5b0dc73..e587256 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -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] diff --git a/src/api/openapiSpec.js b/src/api/openapiSpec.js index 5c043b7..a88ea7e 100644 --- a/src/api/openapiSpec.js +++ b/src/api/openapiSpec.js @@ -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: [ diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 13bcbbb..265ee7b 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -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 = { diff --git a/src/api/routes/labeltemplate.js b/src/api/routes/labeltemplate.js index 1864ea0..bbe91b1 100644 --- a/src/api/routes/labeltemplate.js +++ b/src/api/routes/labeltemplate.js @@ -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); diff --git a/src/api/routes/modelconversation.js b/src/api/routes/modelconversation.js new file mode 100644 index 0000000..24f7aac --- /dev/null +++ b/src/api/routes/modelconversation.js @@ -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 }); + } + }); +} diff --git a/src/api/routes/publicCatalog.js b/src/api/routes/publicCatalog.js new file mode 100644 index 0000000..4f72c2c --- /dev/null +++ b/src/api/routes/publicCatalog.js @@ -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 }); + } + }); +} diff --git a/src/db/article.js b/src/db/article.js index 99aa6a8..f91c410 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -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) { diff --git a/src/db/modelconversation.js b/src/db/modelconversation.js new file mode 100644 index 0000000..6ef272e --- /dev/null +++ b/src/db/modelconversation.js @@ -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); +} diff --git a/src/db/modelvfs.js b/src/db/modelvfs.js new file mode 100644 index 0000000..5cb9486 --- /dev/null +++ b/src/db/modelvfs.js @@ -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); +} diff --git a/src/db/publicCatalog.js b/src/db/publicCatalog.js new file mode 100644 index 0000000..509521f --- /dev/null +++ b/src/db/publicCatalog.js @@ -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), + })), + }; +} diff --git a/src/db/store.js b/src/db/store.js index aa1d610..40c4d90 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -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; } } + diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js new file mode 100644 index 0000000..a0a4a20 --- /dev/null +++ b/src/services/conversationManager.js @@ -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} + */ +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} 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} 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} */ +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); + } +} diff --git a/src/services/imageCrop.js b/src/services/imageCrop.js index 392268c..5a5afc8 100644 --- a/src/services/imageCrop.js +++ b/src/services/imageCrop.js @@ -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); diff --git a/src/tools/ask-user.js b/src/tools/ask-user.js new file mode 100644 index 0000000..4c35e2a --- /dev/null +++ b/src/tools/ask-user.js @@ -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 }); + }, + }); +} diff --git a/src/tools/dashback-request.js b/src/tools/dashback-request.js new file mode 100644 index 0000000..b4410c3 --- /dev/null +++ b/src/tools/dashback-request.js @@ -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 }); + }, + }); +} diff --git a/src/tools/javascript-worker.js b/src/tools/javascript-worker.js new file mode 100644 index 0000000..a3fc3ab --- /dev/null +++ b/src/tools/javascript-worker.js @@ -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 ``; + } + try { + return JSON.stringify(a, (k, v) => { + if (v && typeof v === 'object') { + if (v.type === 'Buffer' && Array.isArray(v.data)) { + return ``; + } + if (Buffer.isBuffer(v) || ArrayBuffer.isView(v) || (v.constructor && v.constructor.name === 'Buffer')) { + return ``; + } + } + 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 ``; + } + 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, + }); +} +})(); diff --git a/src/tools/javascript.js b/src/tools/javascript.js new file mode 100644 index 0000000..47e5002 --- /dev/null +++ b/src/tools/javascript.js @@ -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; + }, + }); +} diff --git a/src/tools/reset-vfs.js b/src/tools/reset-vfs.js new file mode 100644 index 0000000..03ffc4d --- /dev/null +++ b/src/tools/reset-vfs.js @@ -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 }; + }, + }); +} diff --git a/src/tools/vfs.js b/src/tools/vfs.js new file mode 100644 index 0000000..71aed4b --- /dev/null +++ b/src/tools/vfs.js @@ -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} 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; +}