This commit is contained in:
seb
2026-10-11 12:58:15 +02:00
parent 8262e35e53
commit 1149ffddcb
24 changed files with 575 additions and 261 deletions

View File

@@ -1,6 +1,5 @@
import { LLMOrbiter, OpenRouterProvider } from 'libllmorbiter';
import * as db from '../db/modelconversation.js';
import * as dbCompanySetting from '../db/companysetting.js';
import { getStore } from '../db/store.js';
import env from '../config/env.js';
import { emit as emitCompanyEvent } from '../api/sse.js';
@@ -8,12 +7,12 @@ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { resolve, dirname, extname } from 'path';
import { fileURLToPath } from 'url';
import { randomUUID } from 'node:crypto';
import { buildSystemPrompt } from './prompts.js';
import { createDashbackRequestTool } from '../tools/dashback-request.js';
import { createAskUserTool } from '../tools/ask-user.js';
import { createJavascriptExecuteTool } from '../tools/javascript.js';
import { createResetVfsTool } from '../tools/reset-vfs.js';
import { resolveVfsRoot, prepareVfs, getMimeType } from '../tools/vfs.js';
import { listSkillFiles } from '../tools/skill.js';
import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissues.js';
import { createFindFilesTool } from '../tools/find-files.js';
import { createReadFileTool } from '../tools/read-file.js';
@@ -31,161 +30,14 @@ import { createNetGrossCalcTool } from '../tools/net-gross-calc.js';
import { createSetUserRouteTool } from '../tools/set-user-route.js';
import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js';
// #region Constants
const KEEP_ALIVE_MS = 20_000;
// #endregion
/**
* Builds the SKILL index block for the system prompt.
*
* Listing the skills inline means the model knows what guidance exists BEFORE it decides what
* to do, so it can reach for `read_file` on the right skill instead of guessing a procedure
* from scratch. Only the filename, title and line count are included — the body is read on
* demand, which keeps the prompt small no matter how many skills exist.
*
* @param {string} vfsRoot
* @returns {string}
*/
function buildSkillIndex(vfsRoot) {
// A null root means the VFS could not be resolved (no company selected, or a store
// error). Say so plainly instead of pretending the company has no skills — otherwise the
// model would conclude no guidance exists and invent its own procedure.
if (!vfsRoot) {
return 'AVAILABLE SKILLS: unknown (the virtual filesystem could not be read this turn). Call find_files({ skills: true }) to check.';
}
let skills;
try {
skills = listSkillFiles(vfsRoot);
} catch (err) {
console.warn('conversation: failed to list skill files:', err.message);
return 'AVAILABLE SKILLS: could not be read this turn. Call find_files({ skills: true }) to check.';
}
if (skills.length === 0) {
return [
'AVAILABLE SKILLS: none. This company has no SKILL files yet.',
].join('\n');
}
const width = Math.max(...skills.map((s) => s.path.length));
return [
`AVAILABLE SKILLS (${skills.length}): stored in the VFS root as flat <slug>.SKILL files.`,
'Before starting a task that any of these cover, read the matching skill with read_file and follow it.',
'',
...skills.map((s) => ` ${s.path.padEnd(width)} ${String(s.lines).padStart(4)} lines | ${s.title}`),
].join('\n');
}
/**
* System prompt: base instructions plus the generated API routes reference
* (docs/api/routes.md), read on each run so it stays current.
*
* @param {string} vfsRoot Resolved VFS root for this user's company, used to list SKILL files.
* @param {number} [googleAuthId]
* @returns {Promise<string>}
*/
async function buildSystemPrompt(vfsRoot, googleAuthId) {
let prefix = '';
let suffix = '';
if (googleAuthId) {
try {
const prefixSetting = await dbCompanySetting.getByKey(googleAuthId, 'systemprompt-prefix', { maskWriteonly: false });
prefix = prefixSetting?.value?.trim() || '';
} catch (err) {
console.error('conversation: failed to read systemprompt-prefix:', err.message);
}
try {
const suffixSetting = await dbCompanySetting.getByKey(googleAuthId, 'systemprompt-suffix', { maskWriteonly: false });
suffix = suffixSetting?.value?.trim() || '';
} catch (err) {
console.error('conversation: failed to read systemprompt-suffix:', err.message);
}
}
const today = new Date().toISOString().split('T')[0];
const basePrompt = [
'QuixPOS Article Management System API Assistant',
`Date: ${today}`,
'',
'TOOLS:',
'1. dashback_request: HTTP requests (GET, POST, PUT, PATCH, DELETE) to QuixPOS API.',
'2. ask_user: Prompt user for missing decisions or parameters; pauses until answered.',
'3. javascript_execute: Sandboxed Node env for computation, binary I/O, aggregation. Assign module.exports. Has fetch("/path"), vfs, sharp, ExcelJS.',
'4. find_files: List VFS files (skills=true for SKILL files).',
'5. read_file: Read VFS text/CSV/JSON files (seek/limit windowed).',
'6. patch: Create/overwrite/replace text in VFS files, incl. SKILL files (only text write path).',
'7. search_images: Search Google Images; downloads AVIF candidates to VFS.',
'8. analyze_image: Vision analysis of up to 9 images (VFS paths or "image/<hash>"). Always evaluate images with this tool.',
'9. web_search: Google web search (timePeriod: "day"|"week" for current events).',
'10. url_get: Fetch/scrape web page as markdown.',
'11. pdf_read: Convert PDF to layout text ("txt") or VFS page images ("png").',
'12. reset_vfs: Delete all non-SKILL VFS files.',
'13. rm: Delete a single VFS file (including SKILL files).',
'14. apisuggestionsorissues: Report API bugs or missing endpoints.',
'15. search_articles: Search by name, brand, EAN, SKU, supplier SKU. Returns full articles window (default 3, max 20), offset, total.',
'16. edit_image: Generate/edit images with the company image model (modelslugimage). Supports attachments (low/high quality), sizes 1K/2K/4K, ratios 1:1/4:3/3:4. Output: AVIF in VFS.',
'17. ean_checksum: Validate or generate barcode check digits (EAN-13, EAN-8, UPC-A, GTIN-14).',
'18. net_gross_calc: Convert gross/net with VAT (default 19%). Returns 2-decimal display values and high-precision net for the API.',
'19. set-user-route: Navigate the UI (e.g. "show me the article", "open quote 1001") using the Route Matrix.',
'',
'VFS & SKILLS:',
'- Persistent per-company VFS; root-relative forward-slash paths (e.g. "labels/sheet1.png"). Can host a static website.',
'- User uploads go to "attachments/…", tagged in messages as `[ATTACHMENT name= path= type=]`. Inspect with read_file (text/csv/json), pdf_read (PDF), analyze_image (images), javascript_execute + ExcelJS (spreadsheets).',
'- Markdown links: internet links are "https://…" or dotted hostnames ("hostname.com/file"); anything else is a VFS path (`[download](report.csv)`).',
'- SKILL files: root-only "<slug>.SKILL" markdown (first line = title). Discover via find_files({ skills: true }). ALWAYS read_file the matching skill BEFORE executing a covered task. Propose saving improved procedures as new SKILL files. Exempt from reset_vfs; delete via rm.',
'',
buildSkillIndex(vfsRoot),
'',
'RULES:',
'- PRICES: user input is GROSS, API requires NET. Always use net_gross_calc (4-6 decimals): net = gross / (1 + taxRate / 100).',
'- LABELS (/labeltemplate): Dashback JSON `{ "objects": [ ... ] }`. Do NOT use ZPL.',
'- IMAGES: flow search_images → analyze_image({ paths, prompt }) → upload via fetch in javascript_execute. `image/` is READ-ONLY; write generated images elsewhere (e.g. "images/…").',
'- OUTPUT: Markdown + emoji. Render VFS images inline `![alt](artifacts/example.png)`; other files (PDF, JSON, CSV) need download links.',
'- NAVIGATION: when the user asks to view/open a page or entity, call set-user-route with a path from the matrix.',
'- API: routes are not inlined. BEFORE dashback_request, read_file the api-<section>.SKILL for exact params and response shapes. `api-binary-streaming-routes.SKILL` covers fetch()-only routes.',
'- T* TYPES: `T<n>` (e.g. T2) is shorthand for a reusable object shape. Ids are GLOBAL: the same T<n> always means the same shape in every skill. Each api-<section>.SKILL only lists, in its "Reusable types" block, the T<n> it uses (plus the ones those reference), so a skill shows a subset of the registry, and a T<n> may appear in several skills with an identical definition.',
'',
'BINARY IMAGE UPLOAD (fetch() in javascript_execute, image/avif buffer body):',
'- POST /article/{sku}/image -> 201 { image: { picId, sort, full: { w, h, size }, thumb: { w, h }, hash, thumbHash } }',
'- POST /category/{id}/image -> 201 { category: { id, thumbW, thumbH, thumbHash } }',
'- POST /brand/{id}/image -> 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics: [...] } }',
'',
'ROUTE MATRIX (client routes German, API sections English; skills = api-<section>):',
'| Destination | Path | Example | API sections |',
'|:---|:---|:---|:---|',
'| Article Search | `/artikel?target={query}&filter={filter}&offset={n}` | `/artikel?target=kabel` | article, category, brand |',
'| Article Detail | `/artikel/${encodeURIComponent(sku)}` | `/artikel/ART-10042` | article, category, brand, attributename, propertyname, unit, supplier, printer, labeltemplate, ledger |',
'| Warehouse / Stock | `/artikel/lager` | `/artikel/lager` | warehouse, stock, ledger |',
'| Brands | `/artikel/marken` | `/artikel/marken` | brand |',
'| Label Templates | `/artikel/vorlagen` | `/artikel/vorlagen` | labeltemplate, printer |',
'| Attributes | `/artikel/merkmale` | `/artikel/merkmale` | attributename, attributevalue, propertyname |',
'| Category Root | `/kategorien` | `/kategorien` | category |',
'| Category Subtree | `/kategorien/${slug1}/${slug2}` (encoded slugs) | `/kategorien/elektronik/kabel` | category, article |',
'| Delivery List | `/lieferung` | `/lieferung` | delivery |',
'| New Delivery | `/lieferung/neu` | `/lieferung/neu` | delivery, supplier, article, warehouse, stock |',
'| Delivery Detail | `/lieferung/${deliveryId}` | `/lieferung/42` | delivery, supplier, ledger |',
'| Suppliers | `/lieferung/lieferanten` | `/lieferung/lieferanten` | supplier |',
'| Quote List | `/auftrag` | `/auftrag` | quote |',
'| New Quote | `/auftrag/neu` | `/auftrag/neu` | quote, entity, article |',
'| Quote Detail | `/auftrag/${quoteId}` | `/auftrag/1001` | quote, entity |',
'| Order List | `/auftrag/bestellung` | `/auftrag/bestellung` | order |',
'| New Order | `/auftrag/bestellung/neu` | `/auftrag/bestellung/neu` | order, entity, article |',
'| Order Detail | `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` | order, entity, account, payment, shipping, package |',
'| Customer List | `/kunde` | `/kunde` | entity |',
'| New Customer | `/kunde/neu` | `/kunde/neu` | entity, customergroup, number |',
'| Customer Detail | `/kunde/${entityId}` | `/kunde/312` | entity, customergroup |',
'| Customer Groups | `/kunde/gruppen` | `/kunde/gruppen` | customergroup |',
'| Article History | `/infos/articlehistory` | `/infos/articlehistory` | article (`GET /article/{sku}/history`) |',
'| Global History | `/infos/globalhistory` | `/infos/globalhistory` | company (`GET /company/history`) |',
'| Statistics | `/infos/statistics` | `/infos/statistics` | none; aggregate from order, delivery, ledger |',
].join('\n');
const sections = [];
if (prefix) sections.push(prefix);
sections.push(basePrompt);
if (suffix) sections.push(suffix);
return sections.join('\n\n');
}
// #region Message Helpers
/**
* Per-conversation manager. Owns the SSE subscribers of one conversation and
@@ -391,6 +243,10 @@ export function historyToLlmMessages(history) {
return messages;
}
// #endregion
// #region ConversationManager
export class ConversationManager {
/**
* @param {number} conversationId
@@ -408,6 +264,8 @@ export class ConversationManager {
this.isProcessing = false;
}
// #region Subscription & Broadcast
/**
* Broadcast an SSE event to all subscribers of this conversation.
* @param {string} event
@@ -461,6 +319,10 @@ export class ConversationManager {
return unsubscribe;
}
// #endregion
// #region Attachments
/**
* Saves attachments sent with a user message to the company's VFS
* under `attachments/<filename>` and registers them in `modelvfs`.
@@ -554,6 +416,10 @@ export class ConversationManager {
return tags;
}
// #endregion
// #region Message Handlers
/**
* Handle an incoming user message.
* 1. Extracts text content and saves any attachments into the company VFS (`attachments/`).
@@ -649,7 +515,9 @@ export class ConversationManager {
return toolMsg;
}
// #endregion
// #region LLM Run
/**
* Run the LLM over the stored transcript, stream the answer over SSE,
* persist reasoning, tool calls, and assistant messages, then return to `idle`.
@@ -947,6 +815,10 @@ export class ConversationManager {
this.broadcast('state', error ? { state: 'done', error } : { state: 'done' });
}
// #endregion
// #region Teardown
destroy() {
this.destroyed = true;
this.orbiter?.abort('Conversation manager closed');
@@ -958,8 +830,14 @@ export class ConversationManager {
}
this.subscribers.clear();
}
// #endregion
}
// #endregion
// #region Manager Registry
/** @type {Map<number, ConversationManager>} */
const activeManagers = new Map();
@@ -1011,3 +889,5 @@ export function closeAllManagers() {
activeManagers.delete(id);
}
}
// #endregion