u
This commit is contained in:
@@ -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 ``; 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
|
||||
|
||||
1
src/services/prompts.js
Normal file
1
src/services/prompts.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../tools/prompts.js';
|
||||
Reference in New Issue
Block a user