u
This commit is contained in:
@@ -484,7 +484,6 @@ _none_
|
|||||||
- `modelconversation`: `{}`
|
- `modelconversation`: `{}`
|
||||||
- `modelconversation-<id>`: `{}`
|
- `modelconversation-<id>`: `{}`
|
||||||
- `brand`: `{ sender, brandId }`
|
- `brand`: `{ sender, brandId }`
|
||||||
- `draft`: `{ sender, action, name, content }`
|
|
||||||
|
|
||||||
### Section: /health
|
### Section: /health
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ richer payload.
|
|||||||
| `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
|
| `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 |
|
| `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` |
|
| `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
|
The `brand` payload is `{ sender, brandId }`: `sender` echoes the
|
||||||
caller's `x-client-id` header (empty string if absent) so the sender
|
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` | `{}` | `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 |
|
* | `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` |
|
* | `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
|
* The `brand` payload is `{ sender, brandId }`: `sender` echoes the
|
||||||
* caller's `x-client-id` header (empty string if absent) so the sender
|
* caller's `x-client-id` header (empty string if absent) so the sender
|
||||||
@@ -96,8 +95,6 @@ export function register(app, basePath = '') {
|
|||||||
* payload: "{}"
|
* payload: "{}"
|
||||||
* - name: brand
|
* - name: brand
|
||||||
* payload: "{ sender, brandId }"
|
* payload: "{ sender, brandId }"
|
||||||
* - name: draft
|
|
||||||
* payload: "{ sender, action, name, content }"
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: |
|
* description: |
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ import {
|
|||||||
restoreDefaultApplets,
|
restoreDefaultApplets,
|
||||||
} from '../../tools/vfs.js';
|
} from '../../tools/vfs.js';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import {
|
|
||||||
parseDraftPath,
|
|
||||||
requestDraftRead,
|
|
||||||
requestDraftList,
|
|
||||||
notifyDraftUpdate,
|
|
||||||
} from '../../services/draftManager.js';
|
|
||||||
|
|
||||||
// #region Helpers
|
// #region Helpers
|
||||||
|
|
||||||
@@ -478,18 +472,7 @@ export function register(app, basePath = '') {
|
|||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
const diskFiles = walk(vfsRoot);
|
const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
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));
|
|
||||||
return res.json({ files });
|
return res.json({ files });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = vfsFileErrorStatus(error);
|
const status = vfsFileErrorStatus(error);
|
||||||
@@ -705,9 +688,6 @@ export function register(app, basePath = '') {
|
|||||||
if (isReadOnlyDatabasePath(parsed.name)) {
|
if (isReadOnlyDatabasePath(parsed.name)) {
|
||||||
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
|
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 {
|
try {
|
||||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||||
@@ -816,14 +796,6 @@ export function register(app, basePath = '') {
|
|||||||
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
|
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
|
||||||
return res.status(403).json({ error: 'Cannot rename read-only database paths' });
|
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 {
|
try {
|
||||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
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' });
|
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 buffer;
|
||||||
let mime = parsed.mime;
|
let mime = parsed.mime;
|
||||||
if (typeof req.body?.content === 'string') {
|
if (typeof req.body?.content === 'string') {
|
||||||
@@ -1123,10 +1051,6 @@ export function register(app, basePath = '') {
|
|||||||
if (isReadOnlyDatabasePath(parsed.name)) {
|
if (isReadOnlyDatabasePath(parsed.name)) {
|
||||||
return res.status(403).json({ error: 'Cannot delete read-only database files' });
|
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 {
|
try {
|
||||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||||
@@ -1294,33 +1218,7 @@ export function register(app, basePath = '') {
|
|||||||
return res.status(400).json({ error: parsed.error });
|
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 {
|
try {
|
||||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
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
|
// 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.
|
// 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.
|
// `/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) {
|
function requireSelectedCompany(req) {
|
||||||
const companyId = req.user?.selectedCompanyId;
|
const companyId = req.user?.selectedCompanyId;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function hasActiveClient({ companyId, userId, clientId } = {}) {
|
|||||||
* Broadcast or target a named signal to subscribers.
|
* Broadcast or target a named signal to subscribers.
|
||||||
* If `targetClientId` or `targetUserId` is specified, sends only to matching clients of that user.
|
* 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
|
* @param {{ companyId: number, data?: string|object, targetClientId?: string, targetUserId?: number }} options
|
||||||
*/
|
*/
|
||||||
export function emit(signal, { companyId, data, targetClientId, targetUserId }) {
|
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
|
# Overview
|
||||||
|
|
||||||
This skill guides the automated and interactive processing of inbound supplier documents (invoices / *Rechnungen* and delivery notes / *Lieferscheine*).
|
This skill guides the automated and interactive processing of inbound supplier documents (invoices / *Rechnungen* and delivery notes / *Lieferscheine*).
|
||||||
It covers:
|
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.
|
- **Document Data Extraction**: Extracting structured metadata, packaging units (VPE), units of measurement, line items, and notes from document text or images.
|
||||||
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.
|
- **Delta & Batching Updates**: Formatting and returning extracted product and document metadata via the `output_manifest` tool.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 { createFindFilesTool } from '../tools/find-files.js';
|
||||||
import { createReadFileTool } from '../tools/read-file.js';
|
import { createReadFileTool } from '../tools/read-file.js';
|
||||||
import { createPatchTool } from '../tools/patch.js';
|
import { createPatchTool } from '../tools/patch.js';
|
||||||
import { setCachedDraft, parseDraftPath } from '../services/draftManager.js';
|
|
||||||
import { createRmTool } from '../tools/rm.js';
|
import { createRmTool } from '../tools/rm.js';
|
||||||
import { createSearchImagesTool } from '../tools/search-images.js';
|
import { createSearchImagesTool } from '../tools/search-images.js';
|
||||||
import { createAnalyzeImageTool } from '../tools/analyze-image.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' });
|
toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' });
|
||||||
await db.setState(googleAuthId, this.conversationId, 'busy');
|
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) {
|
} catch (err) {
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -773,8 +740,8 @@ export class ConversationManager {
|
|||||||
// can never end up pointed at different companies.
|
// can never end up pointed at different companies.
|
||||||
createJavascriptExecuteTool({ googleAuthId }),
|
createJavascriptExecuteTool({ googleAuthId }),
|
||||||
createFindFilesTool({ googleAuthId }),
|
createFindFilesTool({ googleAuthId }),
|
||||||
createReadFileTool({ googleAuthId, conversationId: this.conversationId }),
|
createReadFileTool({ googleAuthId }),
|
||||||
createPatchTool({ googleAuthId, conversationId: this.conversationId }),
|
createPatchTool({ googleAuthId }),
|
||||||
createRmTool({ googleAuthId }),
|
createRmTool({ googleAuthId }),
|
||||||
createSearchImagesTool({ googleAuthId }),
|
createSearchImagesTool({ googleAuthId }),
|
||||||
createAnalyzeImageTool({ googleAuthId, model }),
|
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 { isSkillPath, listSkillFiles, SKILL_EXT } from './skill.js';
|
||||||
import { prepareVfs } from './vfs.js';
|
import { prepareVfs } from './vfs.js';
|
||||||
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.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).
|
* 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 absDir = path.resolve(vfsRoot, cleanDir);
|
||||||
const rel = path.relative(vfsRoot, absDir);
|
const rel = path.relative(vfsRoot, absDir);
|
||||||
@@ -213,16 +188,6 @@ export function createFindFilesTool({ googleAuthId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const all = walkVfs(absDir, vfsRoot, recursive === true, 0, maxDepth);
|
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
|
// Directories first, then alphabetical — makes a truncated listing predictable
|
||||||
// instead of arbitrary.
|
// instead of arbitrary.
|
||||||
all.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === 'dir' ? -1 : 1));
|
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 fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter';
|
import { defineTool, ToolError } from 'libllmorbiter';
|
||||||
import { isReadOnlyDatabasePath } from './vfs.js';
|
import { isReadOnlyDatabasePath } from './vfs.js';
|
||||||
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
|
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
|
||||||
import { prepareVfs, syncVfsToDb } from './vfs.js';
|
import { prepareVfs, syncVfsToDb } from './vfs.js';
|
||||||
import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
||||||
import { getStore } from '../db/store.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).
|
* 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.
|
* 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({
|
return defineTool({
|
||||||
name: 'patch',
|
name: 'patch',
|
||||||
description: TOOL_DESCRIPTIONS.patch,
|
description: TOOL_DESCRIPTIONS.patch,
|
||||||
@@ -177,12 +176,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftInfo = parseDraftPath(clean);
|
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Apply ──────────────────────────────────────────────────────
|
// ── Apply ──────────────────────────────────────────────────────
|
||||||
@@ -190,57 +184,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
|
|||||||
const touchedSkills = new Set();
|
const touchedSkills = new Set();
|
||||||
|
|
||||||
for (const v of validated) {
|
for (const v of validated) {
|
||||||
const { op, clean, abs, isSkill, draftInfo } = v;
|
const { op, clean, abs, isSkill } = 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 isCreate = op.search === null || op.search === undefined || op.search === '';
|
const isCreate = op.search === null || op.search === undefined || op.search === '';
|
||||||
const exists = fs.existsSync(abs);
|
const exists = fs.existsSync(abs);
|
||||||
@@ -318,10 +262,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
|
|||||||
if (isSkill) touchedSkills.add(clean);
|
if (isSkill) touchedSkills.add(clean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist immediately for disk files — unlike javascript_execute, there is no trailing sync, so a
|
if (applied.length > 0) {
|
||||||
// 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) {
|
|
||||||
try {
|
try {
|
||||||
await syncVfsToDb({ googleAuthId, vfsRoot });
|
await syncVfsToDb({ googleAuthId, vfsRoot });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter';
|
import { defineTool, ToolError } from 'libllmorbiter';
|
||||||
import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
|
import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
|
||||||
import { getApiRouteSkill } from './routes-skills.js';
|
import { getApiRouteSkill } from './routes-skills.js';
|
||||||
import { prepareVfs } from './vfs.js';
|
import { prepareVfs } from './vfs.js';
|
||||||
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
||||||
import { parseDraftPath, requestDraftRead } from '../services/draftManager.js';
|
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 400;
|
const DEFAULT_LIMIT = 400;
|
||||||
const MAX_LIMIT = 5000;
|
const MAX_LIMIT = 5000;
|
||||||
@@ -58,9 +57,9 @@ function looksBinary(buf) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the read_file tool bound to a company's virtual filesystem.
|
* 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({
|
return defineTool({
|
||||||
name: 'read_file',
|
name: 'read_file',
|
||||||
description: TOOL_DESCRIPTIONS.read_file,
|
description: TOOL_DESCRIPTIONS.read_file,
|
||||||
@@ -109,28 +108,7 @@ export function createReadFileTool({ googleAuthId, conversationId }) {
|
|||||||
throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`);
|
throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftInfo = parseDraftPath(clean);
|
const virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
|
||||||
if (draftInfo.isFolder) {
|
|
||||||
throw new ToolError('"drafts" is a directory. Use find_files to list its contents.');
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
if (!virtualSkill && !fs.existsSync(absPath)) {
|
||||||
throw new ToolError(
|
throw new ToolError(
|
||||||
`File not found in virtual filesystem: "${clean}". ` +
|
`File not found in virtual filesystem: "${clean}". ` +
|
||||||
@@ -140,8 +118,7 @@ export function createReadFileTool({ googleAuthId, conversationId }) {
|
|||||||
if (!virtualSkill && !fs.statSync(absPath).isFile()) {
|
if (!virtualSkill && !fs.statSync(absPath).isFile()) {
|
||||||
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
|
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
|
||||||
}
|
}
|
||||||
raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
|
const raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
|
||||||
}
|
|
||||||
if (looksBinary(raw)) {
|
if (looksBinary(raw)) {
|
||||||
const ext = path.extname(clean).toLowerCase();
|
const ext = path.extname(clean).toLowerCase();
|
||||||
throw new ToolError(
|
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 env from '../config/env.js';
|
||||||
import { getDefaultSkills } from './skill.js';
|
import { getDefaultSkills } from './skill.js';
|
||||||
import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.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 FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
|
||||||
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
|
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
|
||||||
@@ -362,16 +354,6 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
|
|
||||||
const vfs = {
|
const vfs = {
|
||||||
readFileSync: (p, opt) => {
|
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);
|
const hash = extractFullHash(p);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const buf = syncFetchImage(hash);
|
const buf = syncFetchImage(hash);
|
||||||
@@ -414,19 +396,6 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
writeFileSync: (p, data, opt) => {
|
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);
|
assertNotReadOnlyDatabasePath(p);
|
||||||
const diskPath = safeResolve(p);
|
const diskPath = safeResolve(p);
|
||||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||||
@@ -467,8 +436,6 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
existsSync: (p) => {
|
existsSync: (p) => {
|
||||||
const draftInfo = parseDraftPath(p);
|
|
||||||
if (draftInfo.isFolder || draftInfo.isDraft) return true;
|
|
||||||
const hash = extractFullHash(p);
|
const hash = extractFullHash(p);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const buf = syncFetchImage(hash);
|
const buf = syncFetchImage(hash);
|
||||||
@@ -483,31 +450,6 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
statSync: (p, opt) => {
|
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);
|
const hash = extractFullHash(p);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const buf = syncFetchImage(hash);
|
const buf = syncFetchImage(hash);
|
||||||
@@ -530,29 +472,15 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
lstatSync: (p, opt) => vfs.statSync(p, opt),
|
lstatSync: (p, opt) => vfs.statSync(p, opt),
|
||||||
|
|
||||||
readdirSync: (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]:|\/)+/, '') : '';
|
const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : '';
|
||||||
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
|
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const diskPath = safeResolve(p);
|
const diskPath = safeResolve(p);
|
||||||
const entries = fs.readdirSync(diskPath, opt);
|
return 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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mkdirSync: (p, 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);
|
assertNotReadOnlyDatabasePath(p);
|
||||||
const diskPath = safeResolve(p);
|
const diskPath = safeResolve(p);
|
||||||
try {
|
try {
|
||||||
@@ -562,30 +490,18 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
unlinkSync: (p) => {
|
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);
|
assertNotReadOnlyDatabasePath(p);
|
||||||
const diskPath = safeResolve(p);
|
const diskPath = safeResolve(p);
|
||||||
return fs.unlinkSync(diskPath);
|
return fs.unlinkSync(diskPath);
|
||||||
},
|
},
|
||||||
|
|
||||||
rmdirSync: (p, opt) => {
|
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);
|
assertNotReadOnlyDatabasePath(p);
|
||||||
const diskPath = safeResolve(p);
|
const diskPath = safeResolve(p);
|
||||||
return fs.rmdirSync(diskPath, opt);
|
return fs.rmdirSync(diskPath, opt);
|
||||||
},
|
},
|
||||||
|
|
||||||
rmSync: (p, 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);
|
assertNotReadOnlyDatabasePath(p);
|
||||||
const diskPath = safeResolve(p);
|
const diskPath = safeResolve(p);
|
||||||
return fs.rmSync(diskPath, opt);
|
return fs.rmSync(diskPath, opt);
|
||||||
@@ -593,17 +509,6 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
|
|
||||||
promises: {
|
promises: {
|
||||||
readFile: async (p, opt) => {
|
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);
|
const hash = extractFullHash(p);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const buf = syncFetchImage(hash);
|
const buf = syncFetchImage(hash);
|
||||||
@@ -631,29 +536,7 @@ export function createVfs(vfsRoot, options = {}) {
|
|||||||
}
|
}
|
||||||
return vfs.readFileSync(p, opt);
|
return vfs.readFileSync(p, opt);
|
||||||
},
|
},
|
||||||
writeFile: async (p, data, opt) => {
|
writeFile: async (p, data, opt) => vfs.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 {}
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
|
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
|
||||||
stat: async (p, opt) => vfs.statSync(p, opt),
|
stat: async (p, opt) => vfs.statSync(p, opt),
|
||||||
readdir: async (p, opt) => vfs.readdirSync(p, opt),
|
readdir: async (p, opt) => vfs.readdirSync(p, opt),
|
||||||
|
|||||||
Reference in New Issue
Block a user