u
This commit is contained in:
@@ -484,7 +484,6 @@ _none_
|
||||
- `modelconversation`: `{}`
|
||||
- `modelconversation-<id>`: `{}`
|
||||
- `brand`: `{ sender, brandId }`
|
||||
- `draft`: `{ sender, action, name, content }`
|
||||
|
||||
### Section: /health
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ richer payload.
|
||||
| `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
|
||||
| `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
|
||||
| `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
|
||||
| `draft` | `{ sender, action, name, content }` | Draft update on `drafts/quote`, `drafts/order`, `drafts/delivery` (`sender` echoes the caller's `x-client-id`) |
|
||||
|
||||
The `brand` payload is `{ sender, brandId }`: `sender` echoes the
|
||||
caller's `x-client-id` header (empty string if absent) so the sender
|
||||
|
||||
@@ -53,7 +53,6 @@ export function register(app, basePath = '') {
|
||||
* | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
|
||||
* | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
|
||||
* | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
|
||||
* | `draft` | `{ sender, action, name, content }` | Draft update on `drafts/quote`, `drafts/order`, `drafts/delivery` (`sender` echoes the caller's `x-client-id`) |
|
||||
*
|
||||
* The `brand` payload is `{ sender, brandId }`: `sender` echoes the
|
||||
* caller's `x-client-id` header (empty string if absent) so the sender
|
||||
@@ -96,8 +95,6 @@ export function register(app, basePath = '') {
|
||||
* payload: "{}"
|
||||
* - name: brand
|
||||
* payload: "{ sender, brandId }"
|
||||
* - name: draft
|
||||
* payload: "{ sender, action, name, content }"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: |
|
||||
|
||||
@@ -11,12 +11,6 @@ import {
|
||||
restoreDefaultApplets,
|
||||
} from '../../tools/vfs.js';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
parseDraftPath,
|
||||
requestDraftRead,
|
||||
requestDraftList,
|
||||
notifyDraftUpdate,
|
||||
} from '../../services/draftManager.js';
|
||||
|
||||
// #region Helpers
|
||||
|
||||
@@ -478,18 +472,7 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
return list;
|
||||
}
|
||||
const diskFiles = walk(vfsRoot);
|
||||
let drafts = [];
|
||||
try {
|
||||
drafts = await requestDraftList({
|
||||
companyId: selected.companyId,
|
||||
userId: req.user.id,
|
||||
clientId: req.headers?.['x-client-id'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to request drafts from frontend:', err);
|
||||
}
|
||||
const files = [...diskFiles, ...drafts].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return res.json({ files });
|
||||
} catch (error) {
|
||||
const status = vfsFileErrorStatus(error);
|
||||
@@ -705,9 +688,6 @@ export function register(app, basePath = '') {
|
||||
if (isReadOnlyDatabasePath(parsed.name)) {
|
||||
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
|
||||
}
|
||||
if (parseDraftPath(parsed.name).isFolder || parseDraftPath(parsed.name).isDraft) {
|
||||
return res.status(403).json({ error: 'Cannot create directories in virtual drafts folder' });
|
||||
}
|
||||
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
@@ -816,14 +796,6 @@ export function register(app, basePath = '') {
|
||||
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
|
||||
return res.status(403).json({ error: 'Cannot rename read-only database paths' });
|
||||
}
|
||||
if (
|
||||
parseDraftPath(parsedOld.name).isFolder ||
|
||||
parseDraftPath(parsedOld.name).isDraft ||
|
||||
parseDraftPath(parsedNew.name).isFolder ||
|
||||
parseDraftPath(parsedNew.name).isDraft
|
||||
) {
|
||||
return res.status(403).json({ error: 'Cannot rename virtual draft files or folders' });
|
||||
}
|
||||
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
@@ -967,50 +939,6 @@ export function register(app, basePath = '') {
|
||||
return res.status(403).json({ error: 'Cannot modify read-only database files' });
|
||||
}
|
||||
|
||||
const draftInfo = parseDraftPath(parsed.name);
|
||||
if (draftInfo.isFolder) {
|
||||
return res.status(400).json({ error: 'Cannot write directly to directory' });
|
||||
}
|
||||
if (draftInfo.isDraft) {
|
||||
let strContent;
|
||||
if (typeof req.body?.content === 'string') {
|
||||
strContent = req.body.content;
|
||||
} else if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
|
||||
strContent = req.body.content !== undefined
|
||||
? (typeof req.body.content === 'string' ? req.body.content : JSON.stringify(req.body.content))
|
||||
: JSON.stringify(req.body);
|
||||
} else if (Buffer.isBuffer(req.body)) {
|
||||
strContent = req.body.toString('utf8');
|
||||
} else if (typeof req.body === 'string') {
|
||||
strContent = req.body;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'No content provided' });
|
||||
}
|
||||
|
||||
let parsedContent = strContent;
|
||||
try {
|
||||
parsedContent = JSON.parse(strContent);
|
||||
} catch {}
|
||||
|
||||
const updated = notifyDraftUpdate({
|
||||
companyId: selected.companyId,
|
||||
userId: req.user.id,
|
||||
clientId: req.headers?.['x-client-id'],
|
||||
name: draftInfo.name,
|
||||
content: parsedContent,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
file: {
|
||||
name: `drafts/${draftInfo.name}`,
|
||||
size: updated.size,
|
||||
mime: 'application/json; charset=utf-8',
|
||||
updated_at: updated.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let buffer;
|
||||
let mime = parsed.mime;
|
||||
if (typeof req.body?.content === 'string') {
|
||||
@@ -1123,10 +1051,6 @@ export function register(app, basePath = '') {
|
||||
if (isReadOnlyDatabasePath(parsed.name)) {
|
||||
return res.status(403).json({ error: 'Cannot delete read-only database files' });
|
||||
}
|
||||
const draftDeleteInfo = parseDraftPath(parsed.name);
|
||||
if (draftDeleteInfo.isFolder || draftDeleteInfo.isDraft) {
|
||||
return res.status(403).json({ error: 'Cannot delete virtual draft files' });
|
||||
}
|
||||
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
@@ -1294,33 +1218,7 @@ export function register(app, basePath = '') {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
const draftGetInfo = parseDraftPath(parsed.name);
|
||||
if (draftGetInfo.isFolder) {
|
||||
return res.status(400).json({ error: 'Cannot download a directory directly' });
|
||||
}
|
||||
if (draftGetInfo.isDraft) {
|
||||
try {
|
||||
const content = await requestDraftRead({
|
||||
companyId: selected.companyId,
|
||||
userId: req.user.id,
|
||||
clientId: req.headers?.['x-client-id'],
|
||||
name: draftGetInfo.name,
|
||||
});
|
||||
const strContent = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
|
||||
const buffer = Buffer.from(strContent, 'utf8');
|
||||
const effectiveMime = 'application/json; charset=utf-8';
|
||||
const fallbackName = `${draftGetInfo.name}.json`;
|
||||
|
||||
res.setHeader('Content-Type', effectiveMime);
|
||||
res.setHeader('Content-Length', String(buffer.length));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fallbackName}"`);
|
||||
return res.status(200).send(buffer);
|
||||
} catch (err) {
|
||||
return res.status(404).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { parseVfsRef, vfsFileErrorStatus } from './vfs.js';
|
||||
// VFS paths that are owned by their own literal route in `vfs.js`. They are registered as
|
||||
// separate Express handlers, so this wildcard must defer to them instead of treating e.g.
|
||||
// `/vfs/upload` as an applet path. Deferring keeps the two files order-independent.
|
||||
const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename', 'drafts', 'skills', 'applets']);
|
||||
const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename', 'skills', 'applets']);
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
|
||||
@@ -75,7 +75,7 @@ export function hasActiveClient({ companyId, userId, clientId } = {}) {
|
||||
* Broadcast or target a named signal to subscribers.
|
||||
* If `targetClientId` or `targetUserId` is specified, sends only to matching clients of that user.
|
||||
*
|
||||
* @param {string} signal - SSE event name (e.g. `category`, `draft`, `modelconversation`)
|
||||
* @param {string} signal - SSE event name (e.g. `category`, `articles`, `modelconversation`)
|
||||
* @param {{ companyId: number, data?: string|object, targetClientId?: string, targetUserId?: number }} options
|
||||
*/
|
||||
export function emit(signal, { companyId, data, targetClientId, targetUserId }) {
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
VFS Drafts: Collaborative Quote and Order Editing
|
||||
|
||||
# Overview
|
||||
|
||||
The virtual directory `drafts/` in VFS allows the LLM agent and the frontend user to collaborate in real time on in-progress commercial documents.
|
||||
Drafts are stateful forms maintained in the frontend application and synchronized with the backend.
|
||||
|
||||
There are two virtual draft files:
|
||||
1. `drafts/quote` : Sales Quote / Angebot (Customer sales proposal)
|
||||
2. `drafts/order` : Sales Order / Auftrag & Bestellung (Customer order confirmation)
|
||||
|
||||
---
|
||||
|
||||
# Draft JSON Schemas
|
||||
|
||||
## 1. Common Manifest Envelope Schema
|
||||
|
||||
Every draft file contains a root object with a `manifest` property conforming to this JSON Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "DraftManifestEnvelope",
|
||||
"type": "object",
|
||||
"required": ["manifest"],
|
||||
"properties": {
|
||||
"manifest": {
|
||||
"type": "object",
|
||||
"required": ["kind", "mode", "lines"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["quote", "order"],
|
||||
"description": "Document type"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["create", "edit"],
|
||||
"description": "Whether creating a new document or editing an existing saved one"
|
||||
},
|
||||
"id": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Database document ID (null for new drafts, integer when editing)"
|
||||
},
|
||||
"date": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date",
|
||||
"description": "ISO date string (YYYY-MM-DD) or null"
|
||||
},
|
||||
"partyId": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Customer entity.id for quotes and orders"
|
||||
},
|
||||
"number": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Document identifier. Null for automatic numbering upon commit"
|
||||
},
|
||||
"externalNumber": {
|
||||
"type": ["string", "null"],
|
||||
"description": "External reference (e.g. customer PO number, supplier delivery slip number)"
|
||||
},
|
||||
"comment": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Document-level remarks or notes"
|
||||
},
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"description": "Ordered line items of the document"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Sales Quote Draft (`drafts/quote`)
|
||||
|
||||
Used for preparing customer sales proposals (Angebote).
|
||||
|
||||
### Complete Quote JSON Schema
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "QuoteDraft",
|
||||
"type": "object",
|
||||
"required": ["manifest"],
|
||||
"properties": {
|
||||
"manifest": {
|
||||
"type": "object",
|
||||
"required": ["kind", "mode", "lines"],
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "quote" },
|
||||
"mode": { "type": "string", "enum": ["create", "edit"] },
|
||||
"id": { "type": ["integer", "null"] },
|
||||
"date": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date",
|
||||
"description": "Offer validity expiration date (Gültig bis, YYYY-MM-DD)"
|
||||
},
|
||||
"partyId": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Customer entity ID (entity.id)"
|
||||
},
|
||||
"number": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Quote number (null for auto-generation)"
|
||||
},
|
||||
"externalNumber": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Customer project or RFQ reference"
|
||||
},
|
||||
"comment": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional quotation remark"
|
||||
},
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "amount", "net", "taxrate"],
|
||||
"properties": {
|
||||
"pos": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position line number (1, 2, 3...)"
|
||||
},
|
||||
"sku": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Catalogue article SKU, or null for custom service/text position"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Title or designation of the position"
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Quoted quantity"
|
||||
},
|
||||
"pu": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"default": 1,
|
||||
"description": "Packing unit / price unit (Preiseinheit, default 1)"
|
||||
},
|
||||
"net": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Net unit price in EUR"
|
||||
},
|
||||
"gross": {
|
||||
"type": ["number", "null"],
|
||||
"description": "Gross unit price in EUR (net * (1 + taxrate/100))"
|
||||
},
|
||||
"taxrate": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "VAT rate percentage (e.g. 19 or 7)"
|
||||
},
|
||||
"rebate": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"default": 0,
|
||||
"description": "Line discount percentage (0 to 100)"
|
||||
},
|
||||
"unit": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Unit of measurement (e.g. 'Stück', 'm', 'kg', 'Pauschal')"
|
||||
},
|
||||
"comment": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional line-specific comment or specification"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Quote Example
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
"kind": "quote",
|
||||
"mode": "create",
|
||||
"id": null,
|
||||
"date": "2026-11-30",
|
||||
"partyId": 104,
|
||||
"number": null,
|
||||
"externalNumber": "ANFRAGE-2026-441",
|
||||
"comment": "Angebot freibleibend, Lieferung frei Haus ab 250 EUR.",
|
||||
"lines": [
|
||||
{
|
||||
"pos": 1,
|
||||
"sku": "42001257",
|
||||
"name": "1 Pot 8L System inkl. 47L Wasserfass",
|
||||
"amount": 2,
|
||||
"pu": 1,
|
||||
"net": 58.74,
|
||||
"gross": 69.90,
|
||||
"taxrate": 19,
|
||||
"rebate": 0,
|
||||
"unit": "Stück",
|
||||
"comment": null
|
||||
},
|
||||
{
|
||||
"pos": 2,
|
||||
"sku": null,
|
||||
"name": "Aufbau und Fachberatung vor Ort",
|
||||
"amount": 1.5,
|
||||
"pu": 1,
|
||||
"net": 80.00,
|
||||
"gross": 95.20,
|
||||
"taxrate": 19,
|
||||
"rebate": 10,
|
||||
"unit": "Std",
|
||||
"comment": "Inklusive Anfahrt Zone 1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Sales Order Draft (`drafts/order`)
|
||||
|
||||
Used for customer order confirmations and sales orders (Aufträge & Bestellungen).
|
||||
|
||||
### Complete Order JSON Schema
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "OrderDraft",
|
||||
"type": "object",
|
||||
"required": ["manifest"],
|
||||
"properties": {
|
||||
"manifest": {
|
||||
"type": "object",
|
||||
"required": ["kind", "mode", "lines"],
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "order" },
|
||||
"mode": { "type": "string", "enum": ["create", "edit"] },
|
||||
"id": { "type": ["integer", "null"] },
|
||||
"date": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date",
|
||||
"description": "Expected delivery date (Liefertermin, YYYY-MM-DD)"
|
||||
},
|
||||
"partyId": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Customer entity ID (entity.id)"
|
||||
},
|
||||
"number": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Order number (null for auto-generation)"
|
||||
},
|
||||
"externalNumber": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Customer purchase order / PO number (Kundenbestellnummer)"
|
||||
},
|
||||
"comment": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional order remark or delivery instruction"
|
||||
},
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "amount", "net", "taxrate"],
|
||||
"properties": {
|
||||
"pos": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position line number (1, 2, 3...)"
|
||||
},
|
||||
"sku": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Article SKU"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Line item description"
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Ordered quantity"
|
||||
},
|
||||
"pu": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"default": 1,
|
||||
"description": "Packaging unit / price unit (default 1)"
|
||||
},
|
||||
"net": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Net unit price in EUR"
|
||||
},
|
||||
"gross": {
|
||||
"type": ["number", "null"],
|
||||
"description": "Gross unit price in EUR"
|
||||
},
|
||||
"taxrate": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "VAT percentage (19 or 7)"
|
||||
},
|
||||
"rebate": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"default": 0,
|
||||
"description": "Line discount percentage"
|
||||
},
|
||||
"unit": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Unit of measurement"
|
||||
},
|
||||
"comment": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Line comment or customer note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Order Example
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
"kind": "order",
|
||||
"mode": "create",
|
||||
"id": null,
|
||||
"date": "2026-10-28",
|
||||
"partyId": 104,
|
||||
"number": null,
|
||||
"externalNumber": "PO-2026-8841",
|
||||
"comment": "Bitte Lieferung avisieren unter +49 171 1234567.",
|
||||
"lines": [
|
||||
{
|
||||
"pos": 1,
|
||||
"sku": "42001257",
|
||||
"name": "1 Pot 8L System inkl. 47L Wasserfass",
|
||||
"amount": 5,
|
||||
"pu": 1,
|
||||
"net": 58.74,
|
||||
"gross": 69.90,
|
||||
"taxrate": 19,
|
||||
"rebate": 5,
|
||||
"unit": "Stück",
|
||||
"comment": null
|
||||
},
|
||||
{
|
||||
"pos": 2,
|
||||
"sku": "42000009",
|
||||
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
|
||||
"amount": 2,
|
||||
"pu": 1,
|
||||
"net": 39.50,
|
||||
"gross": 47.01,
|
||||
"taxrate": 19,
|
||||
"rebate": 0,
|
||||
"unit": "Stück",
|
||||
"comment": "Farbe: Schwarz"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,43 +1,11 @@
|
||||
Process Incoming Product and Invoices: Extraction & Delivery Drafts
|
||||
Process Incoming Product and Invoices: Document Extraction & Manifests
|
||||
|
||||
# Overview
|
||||
|
||||
This skill guides the automated and interactive processing of inbound supplier documents (invoices / *Rechnungen* and delivery notes / *Lieferscheine*).
|
||||
It covers:
|
||||
1. **Document Data Extraction**: Extracting structured metadata, packaging units (VPE), units of measurement, line items, and notes from document text/images via the `output_manifest` tool.
|
||||
2. **Goods Inbound Delivery Draft (`drafts/delivery`)**: Storing and maintaining the inbound receipt in the virtual file system (`drafts/delivery`) for real-time collaboration with the user in the UI.
|
||||
|
||||
---
|
||||
|
||||
# 1. Document Extraction Rules & Prompt Logic
|
||||
|
||||
When reading or processing an invoice or delivery note, follow these core rules:
|
||||
|
||||
## General Extraction Rules
|
||||
- **Document Type (`manifestType`)**: `"rechnung"` or `"lieferschein"`. For `"lieferschein"`, prices (`eknetto`) and taxes (`mwst`) must be `null`.
|
||||
- **Exclude Non-Product Lines**: Exclude freight, shipping, postage, pallet charges, packaging disposal fees, and handling surcharges. Only include physical inventory products.
|
||||
- **Line Items**: Extract `position`, `count`, `vpe` (default: 1), `unit`, `articleName`, `liefArtNr`, `mwst` (7, 19, or null), and `eknetto` per piece in euro cents (or null).
|
||||
- **Supplier Matching**: Match the supplier against known company suppliers to identify `lieferant_id` and `lieferantName`. For unknown suppliers, extract `lieferantName` as string and set `lieferant_id` to `null`.
|
||||
- **Notes Strictly in German**: Record all notes, ambiguities, packaging assumptions, or excluded line details in German. These notes assist a human reviewer in double-checking results.
|
||||
- **Delta / Batching Updates**: The `output_manifest` tool consists of three optional components: `basedata`, `notes`, and `items`. Only include a component when it carries genuinely new or changed data; omit unchanged components.
|
||||
- Include `basedata` ONLY when at least one field is currently null/empty or value actually changes.
|
||||
- Include `items` ONLY when new product line items are being added.
|
||||
- Include `notes` ONLY when there is a genuinely new noteworthy detail.
|
||||
|
||||
## VPE (Verpackungseinheit) Logic
|
||||
VPE represents the number of individually sellable units inside a bulk item. If not explicitly split for individual sale, default to 1.
|
||||
- `"20 Boxen á 10 Stück"` -> `vpe = 20`.
|
||||
- `"10 Gläser á 100 Filter"` -> `vpe = 10` (the 10 glasses are sold individually).
|
||||
- `"100 Stück"` -> `vpe = 1` (sold as one bulk pack).
|
||||
- Typical sellable VPEs are containers like *Beutel*, *Gläser*, *Boxen*, *Hefte*, not loose sub-components.
|
||||
- In `"Y Hefte á X Blatt"` the VPE is NOT X, but `Y`.
|
||||
- A "Display" represents a VPE: `"20er Display"` means `vpe = 20`.
|
||||
- `eknetto` is the price of one full VPE.
|
||||
|
||||
## Unit (ME) Rules
|
||||
- The unit (ME) must ALWAYS refer to the individual sellable item (e.g. `Stk.`, `Beutel`, `Flasche`, `Box`, `Set`). It must **NEVER** be `"VE"` or `"VPE"`.
|
||||
- A packaging size like `"VE100"` means the bulk pack contains 100 individual items: set `vpe = 100` and `unit = "Stk."` (or the relevant individual unit).
|
||||
- Example: `"Einhängesiebe Stahl, VE100"` -> `vpe = 100`, `unit = "Stk."`, `count = 1` (one bulk pack of 100 sieves).
|
||||
- **Document Data Extraction**: Extracting structured metadata, packaging units (VPE), units of measurement, line items, and notes from document text or images.
|
||||
- **Delta & Batching Updates**: Formatting and returning extracted product and document metadata via the `output_manifest` tool.
|
||||
|
||||
---
|
||||
|
||||
@@ -125,181 +93,4 @@ The extraction engine outputs structured findings via the `output_manifest` tool
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. Goods Inbound Delivery Draft (`drafts/delivery`)
|
||||
|
||||
Inbound deliveries are staged and edited as drafts in the virtual file system under `drafts/delivery`.
|
||||
This allows the agent and frontend user to review, correct, and finalize stock receipts and purchase costs before committing them into the inventory ledger.
|
||||
|
||||
## Field Mapping from Extracted Manifest to Delivery Draft
|
||||
- **`date`**: Document date (`YYYY-MM-DD`).
|
||||
- **`partyId`**: Supplier ID (`lieferant_id` / `entity.id`).
|
||||
- **`externalNumber`**: Supplier delivery slip number or invoice number (`manifestNr`).
|
||||
- **`comment`**: Human-readable notes, inspection remarks, storage location, or extraction caveats.
|
||||
- **`lines`**:
|
||||
- `pos`: 1-based position line number.
|
||||
- `sku`: Internal article SKU or matched supplier SKU (`liefArtNr`).
|
||||
- `name`: Delivered product name (`articleName`).
|
||||
- `amount`: Quantity delivered (`count`).
|
||||
- `pu`: Packaging unit size (`vpe`, default `1`).
|
||||
- `net`: Purchase cost net per unit in EUR (`eknetto / 100`, or `0` for delivery notes without prices).
|
||||
- `gross`: Purchase cost gross per unit in EUR (`net * (1 + taxrate/100)`).
|
||||
- `taxrate`: Input tax rate percentage (`19`, `7`, or `0`).
|
||||
- `rebate`: Supplier discount percentage (default `0`).
|
||||
- `unit`: Individual sellable unit (e.g. `Stück`, `Beutel`, `Flasche`).
|
||||
- `comment`: Specific batch, lot, or packaging comment.
|
||||
|
||||
---
|
||||
|
||||
## Delivery Draft JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "DeliveryDraft",
|
||||
"type": "object",
|
||||
"required": ["manifest"],
|
||||
"properties": {
|
||||
"manifest": {
|
||||
"type": "object",
|
||||
"required": ["kind", "mode", "lines"],
|
||||
"properties": {
|
||||
"kind": { "type": "string", "const": "delivery" },
|
||||
"mode": { "type": "string", "enum": ["create", "edit"] },
|
||||
"id": { "type": ["integer", "null"] },
|
||||
"date": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date",
|
||||
"description": "Delivery arrival / receipt date (Lieferdatum, YYYY-MM-DD)"
|
||||
},
|
||||
"partyId": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Supplier ID (supplier.id / entity_id)"
|
||||
},
|
||||
"number": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Delivery manifest number (allocated by system sequence upon creation)"
|
||||
},
|
||||
"externalNumber": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Supplier delivery slip number (Lieferschein-Nr. des Lieferanten)"
|
||||
},
|
||||
"comment": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Warehouse receipt remarks or gate notes"
|
||||
},
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "amount", "net", "taxrate"],
|
||||
"properties": {
|
||||
"pos": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position line number (1, 2, 3...)"
|
||||
},
|
||||
"sku": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Article SKU (internal SKU or matched supplier SKU)"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Delivered article name"
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Delivered quantity received into warehouse"
|
||||
},
|
||||
"pu": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"default": 1,
|
||||
"description": "Packaging unit / VPE (default 1)"
|
||||
},
|
||||
"net": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Purchase cost net per unit in EUR (EK netto)"
|
||||
},
|
||||
"gross": {
|
||||
"type": ["number", "null"],
|
||||
"description": "Purchase cost gross per unit in EUR (EK brutto)"
|
||||
},
|
||||
"taxrate": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Input tax percentage (e.g. 19 or 7)"
|
||||
},
|
||||
"rebate": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"default": 0,
|
||||
"description": "Supplier discount percentage"
|
||||
},
|
||||
"unit": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Unit of measurement"
|
||||
},
|
||||
"comment": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Batch number, lot, condition, or storage bin note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delivery Draft Complete Example
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
"kind": "delivery",
|
||||
"mode": "create",
|
||||
"id": null,
|
||||
"date": "2026-10-14",
|
||||
"partyId": 12,
|
||||
"number": null,
|
||||
"externalNumber": "LS-SUP-99412",
|
||||
"comment": "Ware unbeschädigt auf Palette 3 angeliefert.",
|
||||
"lines": [
|
||||
{
|
||||
"pos": 1,
|
||||
"sku": "42001257",
|
||||
"name": "1 Pot 8L System inkl. 47L Wasserfass",
|
||||
"amount": 20,
|
||||
"pu": 1,
|
||||
"net": 38.50,
|
||||
"gross": 45.82,
|
||||
"taxrate": 19,
|
||||
"rebate": 0,
|
||||
"unit": "Stück",
|
||||
"comment": "Palette A-04"
|
||||
},
|
||||
{
|
||||
"pos": 2,
|
||||
"sku": "42000009",
|
||||
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
|
||||
"amount": 50,
|
||||
"pu": 1,
|
||||
"net": 24.00,
|
||||
"gross": 28.56,
|
||||
"taxrate": 19,
|
||||
"rebate": 3,
|
||||
"unit": "Stück",
|
||||
"comment": "Karton 1 bis 5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -17,7 +17,6 @@ import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissue
|
||||
import { createFindFilesTool } from '../tools/find-files.js';
|
||||
import { createReadFileTool } from '../tools/read-file.js';
|
||||
import { createPatchTool } from '../tools/patch.js';
|
||||
import { setCachedDraft, parseDraftPath } from '../services/draftManager.js';
|
||||
import { createRmTool } from '../tools/rm.js';
|
||||
import { createSearchImagesTool } from '../tools/search-images.js';
|
||||
import { createAnalyzeImageTool } from '../tools/analyze-image.js';
|
||||
@@ -503,38 +502,6 @@ export class ConversationManager {
|
||||
toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' });
|
||||
await db.setState(googleAuthId, this.conversationId, 'busy');
|
||||
|
||||
// If this tool response is answering a draft read, cache the draft
|
||||
try {
|
||||
let parsedContent = null;
|
||||
try { parsedContent = JSON.parse(rawContent); } catch {}
|
||||
if (parsedContent && (parsedContent.manifest || parsedContent.lines)) {
|
||||
const messages = await db.listMessages(googleAuthId, this.conversationId);
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m.role === 'assistant' || m.role === 'model') {
|
||||
let p = null;
|
||||
try { p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; } catch {}
|
||||
const tc = p?.tool_calls?.find((c) => c.id === tool_call_id);
|
||||
if (tc) {
|
||||
let tcArgs = {};
|
||||
try { tcArgs = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {}); } catch {}
|
||||
const draftInfo = parseDraftPath(tcArgs.filename || tcArgs.file || '');
|
||||
if (draftInfo.isDraft) {
|
||||
setCachedDraft({
|
||||
companyId: this.companyId,
|
||||
userId: googleAuthId,
|
||||
name: draftInfo.name,
|
||||
content: parsedContent,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[conversationManager] could not cache draft from tool message:', err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
this.isProcessing = false;
|
||||
throw err;
|
||||
@@ -773,8 +740,8 @@ export class ConversationManager {
|
||||
// can never end up pointed at different companies.
|
||||
createJavascriptExecuteTool({ googleAuthId }),
|
||||
createFindFilesTool({ googleAuthId }),
|
||||
createReadFileTool({ googleAuthId, conversationId: this.conversationId }),
|
||||
createPatchTool({ googleAuthId, conversationId: this.conversationId }),
|
||||
createReadFileTool({ googleAuthId }),
|
||||
createPatchTool({ googleAuthId }),
|
||||
createRmTool({ googleAuthId }),
|
||||
createSearchImagesTool({ googleAuthId }),
|
||||
createAnalyzeImageTool({ googleAuthId, model }),
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { emit } from '../api/sse.js';
|
||||
import { getStore } from '../db/store.js';
|
||||
import { DirectReturnError } from 'libllmorbiter';
|
||||
|
||||
export const DRAFT_NAMES = new Set(['quote', 'order', 'delivery']);
|
||||
|
||||
/**
|
||||
* Checks and parses a VFS path to determine if it points to the drafts directory or a draft file.
|
||||
* Accepts paths like:
|
||||
* - 'drafts', '/drafts', 'drafts/'
|
||||
* - 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'vfs://drafts/delivery'
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @returns {{ isFolder: boolean, isDraft: boolean, name?: 'quote'|'order'|'delivery', filename?: string }}
|
||||
*/
|
||||
export function parseDraftPath(filePath) {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return { isFolder: false, isDraft: false };
|
||||
}
|
||||
let clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
clean = clean.replace(/^api\//i, '').replace(/^vfs\//i, '');
|
||||
const segments = clean.split('/').filter(Boolean);
|
||||
if (segments.length === 0) return { isFolder: false, isDraft: false };
|
||||
|
||||
if (segments[0].toLowerCase() === 'drafts') {
|
||||
if (segments.length === 1) {
|
||||
return { isFolder: true, isDraft: false };
|
||||
}
|
||||
if (segments.length === 2) {
|
||||
const rawName = segments[1];
|
||||
const lower = rawName.toLowerCase();
|
||||
const withoutExt = lower.endsWith('.json') ? lower.slice(0, -5) : lower;
|
||||
if (DRAFT_NAMES.has(withoutExt)) {
|
||||
return { isFolder: false, isDraft: true, name: withoutExt, filename: rawName };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { isFolder: false, isDraft: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default empty draft structure.
|
||||
* @param {string} name
|
||||
* @returns {object}
|
||||
*/
|
||||
export function defaultDraftContent(name) {
|
||||
return {
|
||||
manifest: {
|
||||
kind: name,
|
||||
mode: 'create',
|
||||
id: null,
|
||||
date: null,
|
||||
partyId: null,
|
||||
number: null,
|
||||
externalNumber: null,
|
||||
comment: null,
|
||||
lines: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache for drafts: key: `${companyId}:${userId}:${name}` -> { content, updatedAt, size }
|
||||
*/
|
||||
const draftCache = new Map();
|
||||
|
||||
function cacheKey(companyId, userId, name) {
|
||||
return `${companyId}:${userId}:${name}`;
|
||||
}
|
||||
|
||||
export function getCachedDraft({ companyId, userId, name }) {
|
||||
return draftCache.get(cacheKey(companyId, userId, name)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes draft content by deduplicating redundant `items` array when `lines` exists.
|
||||
* @param {any} content
|
||||
* @returns {any}
|
||||
*/
|
||||
export function normalizeDraftContent(content) {
|
||||
if (!content) return content;
|
||||
let parsed = content;
|
||||
let wasString = false;
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
wasString = true;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const target = parsed.manifest && typeof parsed.manifest === 'object' ? parsed.manifest : parsed;
|
||||
if (target && typeof target === 'object') {
|
||||
if (Array.isArray(target.lines) && Array.isArray(target.items)) {
|
||||
delete target.items;
|
||||
} else if (!Array.isArray(target.lines) && Array.isArray(target.items)) {
|
||||
target.lines = target.items;
|
||||
delete target.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
return wasString ? JSON.stringify(parsed, null, 2) : parsed;
|
||||
}
|
||||
|
||||
export function setCachedDraft({ companyId, userId, name, content }) {
|
||||
const normalized = normalizeDraftContent(content);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const str = typeof normalized === 'string' ? normalized : JSON.stringify(normalized ?? {}, null, 2);
|
||||
const entry = {
|
||||
content: normalized,
|
||||
updatedAt,
|
||||
size: Buffer.byteLength(str, 'utf8'),
|
||||
};
|
||||
draftCache.set(cacheKey(companyId, userId, name), entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected company ID for a given googleAuth ID if needed.
|
||||
* @param {number} googleAuthId
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
export async function resolveCompanyForUser(googleAuthId) {
|
||||
if (!googleAuthId) return null;
|
||||
try {
|
||||
const store = await getStore();
|
||||
const row = await store.google_auth.getById(googleAuthId);
|
||||
return row?.selectedCompanyId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a draft content.
|
||||
* Stateless model:
|
||||
* - If cached, returns immediately.
|
||||
* - In conversation: raises DirectReturnError(..., { recordResult: false }) so the assistant
|
||||
* tool call remains the last message in `modelmessage` until answered (exactly like ask_user).
|
||||
* - Outside conversation: returns default empty draft envelope.
|
||||
*
|
||||
* @param {{ companyId?: number, userId: number, conversationId?: number, name: string }} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function requestDraftRead({ companyId, userId, conversationId, name }) {
|
||||
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
|
||||
if (effectiveCompanyId == null) {
|
||||
throw new Error('No company selected');
|
||||
}
|
||||
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
if (cached) {
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
// Halt turn statelessly; assistant tool call stays as the last message in modelmessage, like ask_user
|
||||
throw new DirectReturnError({
|
||||
action: 'draft_read',
|
||||
draft: name,
|
||||
conversationId,
|
||||
}, { recordResult: false });
|
||||
}
|
||||
|
||||
return defaultDraftContent(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request draft listing for VFS.
|
||||
* Returns immediately from cache or default empty draft metadata. Completely stateless without timers or promises.
|
||||
*
|
||||
* @param {{ companyId?: number, userId: number }} options
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function requestDraftList({ companyId, userId }) {
|
||||
const now = new Date().toISOString();
|
||||
return Array.from(DRAFT_NAMES).map((name) => {
|
||||
const cached = companyId ? getCachedDraft({ companyId, userId, name }) : null;
|
||||
return {
|
||||
name: `drafts/${name}`,
|
||||
size: cached ? cached.size : 0,
|
||||
mime: 'application/json; charset=utf-8',
|
||||
updated_at: cached ? cached.updatedAt : now,
|
||||
created_at: cached ? cached.updatedAt : now,
|
||||
isText: true,
|
||||
isImage: false,
|
||||
isPdf: false,
|
||||
isDraft: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify frontend about a draft update via the normal /events SSE endpoint.
|
||||
*
|
||||
* @param {{ companyId: number, userId: number, clientId?: string, name: string, content: any }} options
|
||||
* @returns {{ name: string, size: number, updatedAt: string }}
|
||||
*/
|
||||
export function notifyDraftUpdate({ companyId, userId, clientId, name, content }) {
|
||||
const entry = setCachedDraft({ companyId, userId, name, content });
|
||||
|
||||
emit('draft', {
|
||||
companyId,
|
||||
targetUserId: userId,
|
||||
targetClientId: clientId,
|
||||
data: {
|
||||
sender: clientId || '',
|
||||
action: 'update',
|
||||
name,
|
||||
content,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
name: `drafts/${name}`,
|
||||
size: entry.size,
|
||||
updatedAt: entry.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous/fire-and-forget version of notifyDraftUpdate for sync writes.
|
||||
*
|
||||
* @param {{ googleAuthId: number, clientId?: string, name: string, content: any }} options
|
||||
*/
|
||||
export function notifyDraftUpdateSync({ googleAuthId, clientId, name, content }) {
|
||||
resolveCompanyForUser(googleAuthId).then((companyId) => {
|
||||
if (companyId != null) {
|
||||
notifyDraftUpdate({ companyId, userId: googleAuthId, clientId, name, content });
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[draftManager] notifyDraftUpdateSync error:', err);
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import { isSkillPath, listSkillFiles, SKILL_EXT } from './skill.js';
|
||||
import { prepareVfs } from './vfs.js';
|
||||
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
||||
import { DRAFT_NAMES } from '../services/draftManager.js';
|
||||
|
||||
/**
|
||||
* find_files — lists the contents of the virtual filesystem (VFS).
|
||||
@@ -174,30 +173,6 @@ export function createFindFilesTool({ googleAuthId }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (cleanDir.replace(/\/+$/, '').toLowerCase() === 'drafts') {
|
||||
const draftEntries = Array.from(DRAFT_NAMES).map((name) => ({
|
||||
path: `drafts/${name}`,
|
||||
name,
|
||||
type: 'file',
|
||||
size: null,
|
||||
modified: null,
|
||||
}));
|
||||
const page = draftEntries.slice(seek, seek + vfsLimit);
|
||||
const truncated = Math.max(0, draftEntries.length - (seek + page.length));
|
||||
const result = {
|
||||
kind: 'files',
|
||||
dirname: 'drafts',
|
||||
recursive: recursive === true,
|
||||
total: draftEntries.length,
|
||||
returned: page.length,
|
||||
seek,
|
||||
entries: page,
|
||||
};
|
||||
if (truncated > 0) {
|
||||
result.truncated = truncated;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const absDir = path.resolve(vfsRoot, cleanDir);
|
||||
const rel = path.relative(vfsRoot, absDir);
|
||||
@@ -213,16 +188,6 @@ export function createFindFilesTool({ googleAuthId }) {
|
||||
}
|
||||
|
||||
const all = walkVfs(absDir, vfsRoot, recursive === true, 0, maxDepth);
|
||||
if (cleanDir === '.') {
|
||||
if (!all.some((e) => e.path === 'drafts/')) {
|
||||
all.push({ path: 'drafts/', name: 'drafts', type: 'dir', size: null, modified: null });
|
||||
}
|
||||
if (recursive === true) {
|
||||
for (const name of DRAFT_NAMES) {
|
||||
all.push({ path: `drafts/${name}`, name, type: 'file', size: null, modified: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Directories first, then alphabetical — makes a truncated listing predictable
|
||||
// instead of arbitrary.
|
||||
all.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === 'dir' ? -1 : 1));
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter';
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import { isReadOnlyDatabasePath } from './vfs.js';
|
||||
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
|
||||
import { prepareVfs, syncVfsToDb } from './vfs.js';
|
||||
import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
||||
import { getStore } from '../db/store.js';
|
||||
import { parseDraftPath, requestDraftRead, notifyDraftUpdate, getCachedDraft } from '../services/draftManager.js';
|
||||
|
||||
/**
|
||||
* patch — applies edit operations to files in the virtual filesystem (VFS).
|
||||
@@ -64,9 +63,9 @@ function toLines(text) {
|
||||
|
||||
/**
|
||||
* Creates the patch tool bound to a company's virtual filesystem.
|
||||
* @param {{ googleAuthId: number, conversationId?: number }} ctx
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
*/
|
||||
export function createPatchTool({ googleAuthId, conversationId }) {
|
||||
export function createPatchTool({ googleAuthId }) {
|
||||
return defineTool({
|
||||
name: 'patch',
|
||||
description: TOOL_DESCRIPTIONS.patch,
|
||||
@@ -177,12 +176,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
|
||||
);
|
||||
}
|
||||
|
||||
const draftInfo = parseDraftPath(clean);
|
||||
if (draftInfo.isFolder) {
|
||||
throw new ToolError(`Operation at index ${i}: "${clean}" is a directory and cannot be patched.`);
|
||||
}
|
||||
|
||||
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody, draftInfo });
|
||||
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody });
|
||||
}
|
||||
|
||||
// ── Apply ──────────────────────────────────────────────────────
|
||||
@@ -190,57 +184,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
|
||||
const touchedSkills = new Set();
|
||||
|
||||
for (const v of validated) {
|
||||
const { op, clean, abs, isSkill, draftInfo } = v;
|
||||
|
||||
if (draftInfo?.isDraft) {
|
||||
const isCreate = op.search === null || op.search === undefined || op.search === '';
|
||||
let original = '';
|
||||
if (!isCreate) {
|
||||
try {
|
||||
const readVal = await requestDraftRead({ userId: googleAuthId, conversationId, name: draftInfo.name });
|
||||
original = typeof readVal === 'string' ? readVal : JSON.stringify(readVal ?? {}, null, 2);
|
||||
} catch (err) {
|
||||
if (err instanceof DirectReturnError) throw err;
|
||||
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
|
||||
if (cached) {
|
||||
original = typeof cached.content === 'string' ? cached.content : JSON.stringify(cached.content ?? {}, null, 2);
|
||||
} else {
|
||||
throw new ToolError(`Cannot read draft "${clean}" before patching: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let draftUpdated;
|
||||
if (isCreate) {
|
||||
draftUpdated = typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2);
|
||||
} else {
|
||||
const haystack = original.includes('\r\n') ? original.replace(/\r\n/g, '\n') : original;
|
||||
const needle = typeof op.search === 'string' ? op.search.replace(/\r\n/g, '\n') : '';
|
||||
if (!needle) {
|
||||
throw new ToolError(`Operation at index ${v.index} ("${clean}") has an empty "search". Use "search": null to overwrite.`);
|
||||
}
|
||||
const at = haystack.indexOf(needle);
|
||||
if (at === -1) {
|
||||
throw new ToolError(`SEARCH/REPLACE failed in "${clean}": the search text was not found. Read the draft again with read_file.`);
|
||||
}
|
||||
const replacement = typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2);
|
||||
draftUpdated = haystack.slice(0, at) + replacement + haystack.slice(at + needle.length);
|
||||
}
|
||||
|
||||
let parsed = draftUpdated;
|
||||
try { parsed = JSON.parse(draftUpdated); } catch {}
|
||||
const store = await getStore();
|
||||
const row = await store.google_auth.getById(googleAuthId);
|
||||
const companyId = row?.selectedCompanyId;
|
||||
notifyDraftUpdate({
|
||||
companyId,
|
||||
userId: googleAuthId,
|
||||
name: draftInfo.name,
|
||||
content: parsed,
|
||||
});
|
||||
applied.push(`PATCH ${clean}`);
|
||||
continue;
|
||||
}
|
||||
const { op, clean, abs, isSkill } = v;
|
||||
|
||||
const isCreate = op.search === null || op.search === undefined || op.search === '';
|
||||
const exists = fs.existsSync(abs);
|
||||
@@ -318,10 +262,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
|
||||
if (isSkill) touchedSkills.add(clean);
|
||||
}
|
||||
|
||||
// Persist immediately for disk files — unlike javascript_execute, there is no trailing sync, so a
|
||||
// text edit would otherwise live only in the on-disk cache until some other tool ran.
|
||||
const hasDiskWrites = validated.some((v) => !v.draftInfo?.isDraft);
|
||||
if (hasDiskWrites) {
|
||||
if (applied.length > 0) {
|
||||
try {
|
||||
await syncVfsToDb({ googleAuthId, vfsRoot });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter';
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
|
||||
import { getApiRouteSkill } from './routes-skills.js';
|
||||
import { prepareVfs } from './vfs.js';
|
||||
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
||||
import { parseDraftPath, requestDraftRead } from '../services/draftManager.js';
|
||||
|
||||
const DEFAULT_LIMIT = 400;
|
||||
const MAX_LIMIT = 5000;
|
||||
@@ -58,9 +57,9 @@ function looksBinary(buf) {
|
||||
|
||||
/**
|
||||
* Creates the read_file tool bound to a company's virtual filesystem.
|
||||
* @param {{ googleAuthId: number, conversationId?: number }} ctx
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
*/
|
||||
export function createReadFileTool({ googleAuthId, conversationId }) {
|
||||
export function createReadFileTool({ googleAuthId }) {
|
||||
return defineTool({
|
||||
name: 'read_file',
|
||||
description: TOOL_DESCRIPTIONS.read_file,
|
||||
@@ -109,39 +108,17 @@ export function createReadFileTool({ googleAuthId, conversationId }) {
|
||||
throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`);
|
||||
}
|
||||
|
||||
const draftInfo = parseDraftPath(clean);
|
||||
if (draftInfo.isFolder) {
|
||||
throw new ToolError('"drafts" is a directory. Use find_files to list its contents.');
|
||||
const virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
|
||||
if (!virtualSkill && !fs.existsSync(absPath)) {
|
||||
throw new ToolError(
|
||||
`File not found in virtual filesystem: "${clean}". ` +
|
||||
'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.'
|
||||
);
|
||||
}
|
||||
|
||||
let raw;
|
||||
let virtualSkill = null;
|
||||
if (draftInfo.isDraft) {
|
||||
try {
|
||||
const content = await requestDraftRead({
|
||||
userId: googleAuthId,
|
||||
conversationId,
|
||||
name: draftInfo.name,
|
||||
});
|
||||
const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
|
||||
raw = Buffer.from(str, 'utf8');
|
||||
} catch (err) {
|
||||
if (err instanceof DirectReturnError) throw err;
|
||||
throw new ToolError(`Failed to read draft "${draftInfo.name}": ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
|
||||
if (!virtualSkill && !fs.existsSync(absPath)) {
|
||||
throw new ToolError(
|
||||
`File not found in virtual filesystem: "${clean}". ` +
|
||||
'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.'
|
||||
);
|
||||
}
|
||||
if (!virtualSkill && !fs.statSync(absPath).isFile()) {
|
||||
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
|
||||
}
|
||||
raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
|
||||
if (!virtualSkill && !fs.statSync(absPath).isFile()) {
|
||||
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
|
||||
}
|
||||
const raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
|
||||
if (looksBinary(raw)) {
|
||||
const ext = path.extname(clean).toLowerCase();
|
||||
throw new ToolError(
|
||||
|
||||
121
src/tools/vfs.js
121
src/tools/vfs.js
@@ -4,14 +4,6 @@ import { getStore } from '../db/store.js';
|
||||
import env from '../config/env.js';
|
||||
import { getDefaultSkills } from './skill.js';
|
||||
import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js';
|
||||
import {
|
||||
parseDraftPath,
|
||||
requestDraftRead,
|
||||
notifyDraftUpdate,
|
||||
notifyDraftUpdateSync,
|
||||
getCachedDraft,
|
||||
DRAFT_NAMES,
|
||||
} from '../services/draftManager.js';
|
||||
|
||||
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
|
||||
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
|
||||
@@ -362,16 +354,6 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
|
||||
const vfs = {
|
||||
readFileSync: (p, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isDraft) {
|
||||
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
|
||||
if (cached) {
|
||||
const str = typeof cached.content === 'string' ? cached.content : JSON.stringify(cached.content ?? {}, null, 2);
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? str : Buffer.from(str, 'utf8');
|
||||
}
|
||||
throw new Error(`Virtual draft "${draftInfo.name}" is not cached in memory. Use vfs.promises.readFile() to fetch it asynchronously.`);
|
||||
}
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
@@ -414,19 +396,6 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
},
|
||||
|
||||
writeFileSync: (p, data, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isDraft) {
|
||||
let str = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : JSON.stringify(data);
|
||||
let parsed = str;
|
||||
try { parsed = JSON.parse(str); } catch {}
|
||||
notifyDraftUpdateSync({
|
||||
googleAuthId,
|
||||
clientId: options.clientId,
|
||||
name: draftInfo.name,
|
||||
content: parsed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
@@ -467,8 +436,6 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
}
|
||||
},
|
||||
existsSync: (p) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isFolder || draftInfo.isDraft) return true;
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
@@ -483,31 +450,6 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
},
|
||||
|
||||
statSync: (p, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isFolder) {
|
||||
const now = new Date();
|
||||
return {
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
size: 0,
|
||||
mtime: now,
|
||||
ctime: now,
|
||||
atime: now,
|
||||
};
|
||||
}
|
||||
if (draftInfo.isDraft) {
|
||||
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
|
||||
const now = cached?.updatedAt ? new Date(cached.updatedAt) : new Date();
|
||||
const size = cached?.size ?? 0;
|
||||
return {
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
size,
|
||||
mtime: now,
|
||||
ctime: now,
|
||||
atime: now,
|
||||
};
|
||||
}
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
@@ -530,29 +472,15 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
lstatSync: (p, opt) => vfs.statSync(p, opt),
|
||||
|
||||
readdirSync: (p = '.', opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isFolder) {
|
||||
return Array.from(DRAFT_NAMES);
|
||||
}
|
||||
const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : '';
|
||||
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
|
||||
return [];
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
const entries = fs.readdirSync(diskPath, opt);
|
||||
if ((clean === '' || clean === '.') && Array.isArray(entries)) {
|
||||
if (!entries.some((e) => (typeof e === 'string' ? e : e.name) === 'drafts')) {
|
||||
entries.push('drafts');
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
return fs.readdirSync(diskPath, opt);
|
||||
},
|
||||
|
||||
mkdirSync: (p, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isFolder || draftInfo.isDraft) {
|
||||
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be created on disk.`);
|
||||
}
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
@@ -562,30 +490,18 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
}
|
||||
},
|
||||
unlinkSync: (p) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isFolder || draftInfo.isDraft) {
|
||||
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be unlinked on disk.`);
|
||||
}
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.unlinkSync(diskPath);
|
||||
},
|
||||
|
||||
rmdirSync: (p, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isFolder || draftInfo.isDraft) {
|
||||
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be removed on disk.`);
|
||||
}
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmdirSync(diskPath, opt);
|
||||
},
|
||||
|
||||
rmSync: (p, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isFolder || draftInfo.isDraft) {
|
||||
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be removed on disk.`);
|
||||
}
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmSync(diskPath, opt);
|
||||
@@ -593,17 +509,6 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
|
||||
promises: {
|
||||
readFile: async (p, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isDraft) {
|
||||
const content = await requestDraftRead({
|
||||
userId: googleAuthId,
|
||||
clientId: options.clientId,
|
||||
name: draftInfo.name,
|
||||
});
|
||||
const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? str : Buffer.from(str, 'utf8');
|
||||
}
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
@@ -631,29 +536,7 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
}
|
||||
return vfs.readFileSync(p, opt);
|
||||
},
|
||||
writeFile: async (p, data, opt) => {
|
||||
const draftInfo = parseDraftPath(p);
|
||||
if (draftInfo.isDraft) {
|
||||
let str = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : JSON.stringify(data);
|
||||
let parsed = str;
|
||||
try { parsed = JSON.parse(str); } catch {}
|
||||
let companyId = options.companyId;
|
||||
if (companyId == null && googleAuthId) {
|
||||
const store = await getStore();
|
||||
const row = await store.google_auth.getById(googleAuthId);
|
||||
companyId = row?.selectedCompanyId;
|
||||
}
|
||||
notifyDraftUpdate({
|
||||
companyId,
|
||||
userId: googleAuthId,
|
||||
clientId: options.clientId,
|
||||
name: draftInfo.name,
|
||||
content: parsed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return vfs.writeFileSync(p, data, 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),
|
||||
|
||||
Reference in New Issue
Block a user