import { LLMOrbiter, OpenRouterProvider } from 'libllmorbiter'; import * as db from '../db/modelconversation.js'; import * as dbCompanySetting from '../db/companysetting.js'; import env from '../config/env.js'; import { emit as emitCompanyEvent } from '../api/sse.js'; import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { randomUUID } from 'crypto'; import { createDashbackRequestTool } from '../tools/dashback-request.js'; import { createAskUserTool } from '../tools/ask-user.js'; import { createJavascriptExecuteTool } from '../tools/javascript.js'; import { createResetVfsTool } from '../tools/reset-vfs.js'; import { resolveVfsRoot } 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'; import { createPatchTool } from '../tools/patch.js'; import { createRmTool } from '../tools/rm.js'; import { createSearchImagesTool } from '../tools/search-images.js'; import { createAnalyzeImageTool } from '../tools/analyze-image.js'; import { createWebSearchTool } from '../tools/web-search.js'; import { createUrlGetTool } from '../tools/url-get.js'; import { createPdfReadTool } from '../tools/pdf-read.js'; import { resolveOpenRouterKey, resolveDefaultModel, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js'; const KEEP_ALIVE_MS = 20_000; const ROUTES_DOC_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../../docs/api/routes.md'); /** * 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 .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} */ 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); } } let routesDoc = ''; try { routesDoc = readFileSync(ROUTES_DOC_PATH, 'utf8').trim(); } catch (err) { console.error('conversation: failed to read routes doc:', err); } const today = new Date().toISOString().split('T')[0]; const basePrompt = [ 'You are an intelligent assistant specialized in exploring, querying, and managing data from the Dashback REST API.', `Today's date: ${today}`, '', 'YOUR TOOLS:', '1. dashback_request: Execute HTTP requests (GET, POST, PUT, PATCH, DELETE) against Dashback endpoints.', '2. ask_user: Ask clarifying questions or request decisions from the user if their intent is ambiguous or requires choices. When you call ask_user, execution pauses immediately and waits for the user to provide their response.', '3. javascript_execute: Run JavaScript snippets in a sandbox to compute, aggregate, transform, or analyze data. Assign module.exports to return a value. Snippets can call Dashback API routes using fetch() (e.g. await fetch("/article/2")). A sandboxed virtual filesystem (fs / vfs) is available to store and retrieve artifacts across runs.', '4. find_files: List what is stored in the virtual filesystem (VFS). Call find_files({ skills: true }) to list SKILL files only.', '5. read_file: Read a VFS file as text, windowed with seek/limit for large files.', '6. patch: Create, overwrite, or search-and-replace text in a VFS file. This is the only text write path — it also creates SKILL files.', '7. search_images: Search Google Images and store the candidates in the VFS as AVIF files.', '8. analyze_image: Have a vision model look at up to 9 images (VFS paths or "image/") and return its judgement. This is how you "see" — never claim to have inspected an image yourself.', '9. web_search: Search the web using Google Search. Returns organic results, snippets, and knowledge graph. Supports filtering by timePeriod ("day", "week", "month", "year") — always use "day" or "week" for news and recent events.', '10. url_get: Fetch and scrape the content and markdown of a web page/URL.', '11. pdf_read: Read and convert PDF or text files. Converts PDF to layout-preserved text ("txt") or renders pages into PNG images ("png") saved in the VFS images/ folder.', '12. reset_vfs: Delete files, images, and artifacts stored in the virtual filesystem (VFS) to free up storage or clean up. SKILL files are exempt and survive.', '13. rm: Delete a single file from the VFS. This is how a SKILL file is deleted on purpose, since reset_vfs always spares skills.', '14. apisuggestionsorissues: Report missing API endpoints, bugs, or issues to be triaged. Automatically writes an entry to the company history table for review.', '', 'VIRTUAL FILESYSTEM:', '- You have a persistent per-company VFS. Paths are relative to the VFS root and always use forward slashes, e.g. "labels/sheet1.png".', '- To READ text: find_files (list) then read_file (content). To WRITE text: patch. Binary artifacts (images, PDFs, buffers) go through javascript_execute.', '- Never invent a VFS path. Always confirm with find_files before reading or referencing a file.', '', 'SKILL FILES:', '- A SKILL file is a reusable instruction document: a file named ".SKILL" at the VFS root whose FIRST LINE is its title and whose remaining lines are markdown.', '- SKILL file names may NOT contain "/" — skills are flat, at the root only. Use find_files({ skills: true }) to discover them; this listing is not affected by pagination or by how many artifacts exist.', '- Read a skill with read_file BEFORE starting a task it covers, and follow it. If you learn a better procedure during the task, offer to write it back as a new SKILL file.', '- SKILL files are exempt from reset_vfs: artifacts are disposable, skills are not. Never call reset_vfs expecting to clear skills, and never put throwaway output in a .SKILL file.', '- To delete a skill (or any single file) use rm — it is the only way to remove one. Deletion is final.', '', buildSkillIndex(vfsRoot), '', 'IMAGES:', '- You cannot see images yourself. To evaluate one, call analyze_image and report what the vision model says.', '- Typical flow: search_images("query") → analyze_image({ paths: [...candidates], prompt: "…" }) → upload the winner with dashback_request.', '- The `image/` folder holds database images and is READ-ONLY. Generated images go anywhere else (e.g. "images/…").', '', 'OPERATING GUIDELINES:', '- Refer to the DASHBACK API ROUTES documentation below to find appropriate endpoints, parameters, and HTTP methods.', '- SELLING PRICES: In article responses (GET /article/{sku} and returned article), the selling price field is named net (NOT price). The structure of entries in article.prices is { minmultiplier, net, taxRate, customergroupId }.', '- GROSS vs NET PRICES: Users almost always mean GROSS (brutto, incl. VAT) prices when they specify a price (e.g. "1,60 €"), but the API stores and accepts NET prices only. Convert: net = gross / (1 + taxRate/100). Send net with high precision (at least 4–6 decimal places) so rounding back gives the exact gross. Calculate this in javascript_execute.', '- DATA TYPES & WIRE FORMAT: Dashback strictly validates types (numeric values like weight, price, quantity, IDs must be numbers; flags must be booleans).', '- SHOWING GENERATED IMAGES: Markdown images ARE supported and render inline in the chat. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)`), reference the exact path you wrote with standard markdown `![title](artifacts/example.png)`, which renders inline. Make the alt text descriptive. Other file types (PDF, JSON, CSV) are NOT rendered inline — link to them instead and offer them as a download.', '- LABEL TEMPLATES (/labeltemplate): Label templates are NOT ZPL. content is Dashback\'s JSON label schema: { "objects": [ ... ] }. Do NOT write or suggest ZPL.', '- Format responses clearly using markdown tables, bullet lists, or bold key-value pairs where appropriate.', ...(routesDoc ? ['', 'DASHBACK API ROUTES:', routesDoc] : []), ].join('\n'); const sections = []; if (prefix) sections.push(prefix); sections.push(basePrompt); if (suffix) sections.push(suffix); return sections.join('\n\n'); } /** * Per-conversation manager. Owns the SSE subscribers of one conversation and * runs the LLM (via LLMOrbiter) for each posted user message. * * Persistence: the database holds the transcript (`modelmessage`) and a binary * `idle`/`busy` state. The finer SSE states (`thinking`, `working`, `done`) * and the streamed `delta` chunks are SSE-only. */ export function historyToLlmMessages(history) { const messages = []; for (const m of history) { if (m.role === 'usage' || m.role === 'reasoning') continue; let payload; try { payload = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; } catch { payload = { content: m.message }; } if (m.role === 'user') { messages.push({ role: 'user', content: payload.content ?? (typeof m.message === 'string' ? m.message : '') }); } else if (m.role === 'assistant') { messages.push({ role: 'assistant', content: payload.content ?? null, ...(Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? { tool_calls: payload.tool_calls } : {}), }); } else if (m.role === 'tool') { const toolCallId = payload.tool_call_id ?? ''; const toolName = payload.name ?? ''; const toolParams = payload.parameters ?? payload.arguments ?? '{}'; const prevMsg = messages[messages.length - 1]; if (!prevMsg || prevMsg.role !== 'assistant' || !prevMsg.tool_calls) { messages.push({ role: 'assistant', content: null, tool_calls: [{ id: toolCallId, type: 'function', function: { name: toolName, arguments: typeof toolParams === 'string' ? toolParams : JSON.stringify(toolParams), }, }], }); } else if (!prevMsg.tool_calls.some((tc) => tc.id === toolCallId)) { prevMsg.tool_calls.push({ id: toolCallId, type: 'function', function: { name: toolName, arguments: typeof toolParams === 'string' ? toolParams : JSON.stringify(toolParams), }, }); } messages.push({ role: 'tool', tool_call_id: toolCallId, content: typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content ?? ''), ...(toolName ? { name: toolName } : {}), }); } } return messages; } export class ConversationManager { /** * @param {number} conversationId * @param {{ companyId: number, googleAuthId: number }} meta */ constructor(conversationId, { companyId, googleAuthId }) { this.conversationId = conversationId; this.companyId = companyId; this.googleAuthId = googleAuthId; /** @type {Set<{ res: import('express').Response, keepAlive: NodeJS.Timeout }>} */ this.subscribers = new Set(); /** @type {LLMOrbiter|null} */ this.orbiter = null; this.destroyed = false; this.isProcessing = false; } /** * Broadcast an SSE event to all subscribers of this conversation. * @param {string} event * @param {object} data */ broadcast(event, data) { if (this.destroyed) return; const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; for (const client of this.subscribers) { if (!client.res.writableEnded && !client.res.destroyed) { client.res.write(payload); } } } /** * Add an SSE subscriber for this conversation. * @param {import('express').Response} res * @param {import('express').Request} [req] * @returns {() => void} unsubscribe */ subscribe(res, req) { req?.socket?.setTimeout(0); res.setTimeout?.(0); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); res.flushHeaders?.(); res.write(': ping\n\n'); const client = { res, keepAlive: setInterval(() => { if (res.writableEnded || res.destroyed) return; res.write(': heartbeat\n\n'); }, KEEP_ALIVE_MS), }; this.subscribers.add(client); const unsubscribe = () => { clearInterval(client.keepAlive); this.subscribers.delete(client); }; res.on('close', unsubscribe); req?.on('close', unsubscribe); return unsubscribe; } /** * Handle an incoming user message. * 1. Persists the user message (DB refuses unless `idle`). * 2. Broadcasts it, marks the conversation `busy` (DB) and `thinking` (SSE). * 3. Starts the LLM run in the background; the HTTP request returns immediately. * * @param {number} googleAuthId * @param {{ message: string }} input * @returns {Promise} The created user message * @throws {Error} 'Conversation is not idle' while a run is active. */ async handleUserMessage(googleAuthId, { message }) { if (this.isProcessing) throw new Error('Conversation is not idle'); this.isProcessing = true; let userMsg; try { const rawContent = typeof message === 'string' ? message : (message?.content || ''); const payload = JSON.stringify({ content: rawContent }); userMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'user' }); await db.setState(googleAuthId, this.conversationId, 'busy'); } catch (err) { this.isProcessing = false; throw err; } this.broadcast('state', { state: 'thinking' }); emitCompanyEvent('modelconversation', { companyId: this.companyId }); this.runGeneration(googleAuthId).catch((err) => { console.error(`[ConversationManager ${this.conversationId}] unexpected generation error:`, err); }); return userMsg; } /** * Handle an incoming tool response message (e.g. from the user answering ask_user). * 1. Persists the tool response message (DB refuses unless `idle`). * 2. Broadcasts it, marks the conversation `busy` (DB) and `working` (SSE). * 3. Starts the LLM run in the background; the HTTP request returns immediately. * * @param {number} googleAuthId * @param {{ tool_call_id: string, content?: any, answer?: any }} input * @returns {Promise} The created tool message * @throws {Error} 'Conversation is not idle' while a run is active. */ async handleToolMessage(googleAuthId, { tool_call_id, content, answer, name }) { if (!tool_call_id) throw new Error('tool_call_id is required'); if (this.isProcessing) throw new Error('Conversation is not idle'); this.isProcessing = true; let toolMsg; try { const rawContent = content !== undefined ? (typeof content === 'string' ? content : JSON.stringify(content)) : JSON.stringify({ answer: answer ?? '' }); const payload = JSON.stringify({ content: rawContent, tool_call_id, name: name || 'ask_user', }); toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' }); await db.setState(googleAuthId, this.conversationId, 'busy'); } catch (err) { this.isProcessing = false; throw err; } this.broadcast('state', { state: 'working' }); emitCompanyEvent('modelconversation', { companyId: this.companyId }); this.runGeneration(googleAuthId).catch((err) => { console.error(`[ConversationManager ${this.conversationId}] unexpected generation error:`, err); }); return toolMsg; } /** * Run the LLM over the stored transcript, stream the answer over SSE, * persist reasoning, tool calls, and assistant messages, then return to `idle`. * @param {number} googleAuthId */ async runGeneration(googleAuthId) { let error = null; try { const conversation = await db.get(googleAuthId, this.conversationId); if (conversation.provider !== 'openrouter') { throw new Error(`Unsupported provider: ${conversation.provider}`); } // Fall back to the configured default when the stored model is missing, blank, or // retired. If the stored model was defaulted to the free model and the company now has // a custom key configured, transition to the configured paid default model. const defaultModel = await resolveDefaultModel(googleAuthId); const hasCustomKey = await hasConfiguredOpenRouterKey(googleAuthId); const storedModel = typeof conversation.model === 'string' ? conversation.model.trim() : ''; const model = (!storedModel || RETIRED_MODELS.has(storedModel) || (storedModel === env.openrouterFreeModel && (hasCustomKey || defaultModel !== env.openrouterFreeModel))) ? defaultModel : storedModel; const apiKey = await resolveOpenRouterKey(googleAuthId); // Transcript comes from the database (includes user, assistant, tool, reasoning). const history = await db.listMessages(googleAuthId, this.conversationId); // Resolve the VFS root so the prompt can list the company's SKILL files. A failure // here must not abort the turn — the prompt simply falls back to "no skills". let vfsRoot = null; try { vfsRoot = await resolveVfsRoot(googleAuthId); } catch (err) { console.warn('conversation: could not resolve VFS root for skill index:', err.message); } const messages = [ { role: 'system', content: await buildSystemPrompt(vfsRoot, googleAuthId) }, ...historyToLlmMessages(history), ]; let text = ''; let currentReasoning = ''; this.orbiter = new LLMOrbiter(new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }), { onStatus: (status, meta) => { this.broadcast('state', { state: status, ...(meta || {}) }); }, onReasoning: (reasoning) => { currentReasoning = reasoning; this.broadcast('reasoning', { text: reasoning }); }, onContent: (chunk) => { text += chunk; this.broadcast('delta', { delta: chunk, text }); }, onAssistantMessage: async ({ content, tool_calls, reasoning_details, reasoning }) => { const plain = (reasoning || currentReasoning.trim() || reasoning_details?.find((d) => d?.type === 'reasoning.summary')?.summary || '').trim(); const encrypted = reasoning_details?.find((d) => d?.type === 'reasoning.encrypted')?.data || ''; currentReasoning = ''; if (plain || encrypted) { try { await db.addMessage(googleAuthId, this.conversationId, { role: 'reasoning', message: JSON.stringify({ plain, encrypted }), }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to save reasoning message:`, err); } } const trimmedContent = (content || text).trim(); const hasToolCalls = Array.isArray(tool_calls) && tool_calls.length > 0; if (trimmedContent || hasToolCalls) { try { await db.addMessage(googleAuthId, this.conversationId, { role: 'assistant', message: JSON.stringify({ content: trimmedContent, ...(hasToolCalls ? { tool_calls } : {}), }), }); text = ''; emitCompanyEvent('modelconversation', { companyId: this.companyId }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to save assistant message:`, err); } } }, onToolCall: async ({ name, args, id, callItem }) => { const rawArgs = callItem?.function?.arguments || (typeof args === 'string' ? args : JSON.stringify(args || {})); console.log(`[conversation ${this.conversationId}] tool call: ${name} args=${rawArgs}`); this.broadcast('state', { state: 'toolcall', id: id || callItem?.id || '', name, arguments: rawArgs, content: rawArgs, }); }, onToolResult: async ({ name, result, id, callItem }) => { const rawArgs = callItem?.function?.arguments || ''; this.broadcast('state', { state: 'toolcall', id: id || callItem?.id || '', name, arguments: rawArgs, content: rawArgs, result, }); try { await db.addMessage(googleAuthId, this.conversationId, { role: 'tool', message: JSON.stringify({ name, parameters: rawArgs, content: typeof result === 'string' ? result : JSON.stringify(result), tool_call_id: id || callItem?.id || '', }), }); emitCompanyEvent('modelconversation', { companyId: this.companyId }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to save tool result message:`, err); } }, onToolError: async ({ name, error, id, callItem }) => { console.error(`[ConversationManager ${this.conversationId}] Tool "${name}" errored:`, error); const rawArgs = callItem?.function?.arguments || ''; this.broadcast('state', { state: 'toolcall', id: id || callItem?.id || '', name, arguments: rawArgs, content: rawArgs, error: String(error), }); try { await db.addMessage(googleAuthId, this.conversationId, { role: 'tool', message: JSON.stringify({ name, parameters: rawArgs, content: `Tool error [${name}]: ${error}`, tool_call_id: id || callItem?.id || '', }), }); emitCompanyEvent('modelconversation', { companyId: this.companyId }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to save tool error message:`, err); } }, onUsage: async (u) => { if (u) { this.broadcast('usage', { input: u.input ?? 0, cached: u.cached ?? 0, output: u.output ?? 0, ...(u.cost != null ? { cost: u.cost } : {}), }); try { await db.addMessage(googleAuthId, this.conversationId, { role: 'usage', message: JSON.stringify({ input: u.input ?? 0, cached: u.cached ?? 0, output: u.output ?? 0, ...(u.cost != null ? { cost: u.cost } : {}), }), }); emitCompanyEvent('modelconversation', { companyId: this.companyId }); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to save usage message:`, err); } } // A call that consumed 0 input tokens AND produced 0 output means the // provider returned nothing at all — not a real generation. LLMOrbiter // treats "no content and no tool calls" as "keep looping", so several of // these in a row burn iterations for nothing. Flag them loudly so the // cause is visible instead of showing up as a silent weird loop. if (u.input === 0 && u.output === 0) { console.warn( `[conversation ${this.conversationId}] EMPTY LLM RESPONSE ` + `(iteration reported no usage at all). The provider returned no content ` + `and no tool calls, so the orchestrator will retry. ` + `If this repeats, the upstream model/API is failing — check the model id ` + `and the key.` ); } console.log( `[conversation ${this.conversationId}] usage: input=${u.input}, cached=${u.cached}, output=${u.output}` + (u.cost != null ? `, cost=$${u.cost.toFixed(4)}` : '') ); }, onLLMCall: (call) => { console.log( `[conversation ${this.conversationId}] LLM call #${call.iteration}: ` + `model=${call.model}, messages=${call.messageCount}, flow=${call.messages}, ` + `promptChars=${call.promptSize}` ); }, }); const result = await this.orbiter.run({ messages, model, tools: [ createDashbackRequestTool({ googleAuthId }), createAskUserTool(), // VFS tools take no companyId on purpose — each resolves the user's // currently selected company itself, so the on-disk VFS and the database // can never end up pointed at different companies. createJavascriptExecuteTool({ googleAuthId }), createFindFilesTool({ googleAuthId }), createReadFileTool({ googleAuthId }), createPatchTool({ googleAuthId }), createRmTool({ googleAuthId }), createSearchImagesTool({ googleAuthId }), createAnalyzeImageTool({ googleAuthId, model }), createWebSearchTool({ googleAuthId }), createUrlGetTool({ googleAuthId }), createPdfReadTool({ googleAuthId }), createResetVfsTool({ googleAuthId }), createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }), ], }); if (this.destroyed) return; // Log the shape of every run, including degenerate ones. When nothing comes back // there is nothing persisted and the UI just sits there — this line is the only // clue, so make it explicit about which field was empty. console.log( `[conversation ${this.conversationId}] run finished: ` + `iterations=${result?.iterations}, ` + `content=${result?.content ? `${result.content.length} chars` : 'EMPTY'}, ` + `toolCalls=${result?.toolResults ? Object.keys(result.toolResults).length : 0}, ` + `directReturn=${result?.directReturn === true}` ); // Direct return (e.g. from ask_user) halts without adding an additional assistant message. if (result?.directReturn) { console.log( `[conversation ${this.conversationId}] direct return halted loop: ` + JSON.stringify(result.data) ); return; } const content = (result.content ?? '').trim(); const finalReasoningDetails = (Array.isArray(result.reasoning_details) && result.reasoning_details.length > 0) ? result.reasoning_details : undefined; const finalReasoning = result.reasoning || currentReasoning.trim() || undefined; // Nothing came back at all: no content, no reasoning. Surface it to the user rather // than silently returning to `idle` with an empty turn, which reads as a hang. if (!content && !finalReasoningDetails && !finalReasoning) { console.error( `[conversation ${this.conversationId}] generation produced no content after ` + `${result?.iterations ?? 0} iteration(s). The model returned neither text nor tool calls.` ); error = 'The model returned an empty response. Please try again.'; } } catch (err) { if (this.destroyed) return; error = err.message; console.error(`[ConversationManager ${this.conversationId}] generation failed:`, err); } finally { this.orbiter = null; this.isProcessing = false; } try { await db.setState(googleAuthId, this.conversationId, 'idle'); } catch (err) { console.error(`[ConversationManager ${this.conversationId}] failed to reset state:`, err); } emitCompanyEvent('modelconversation', { companyId: this.companyId }); this.broadcast('state', error ? { state: 'done', error } : { state: 'done' }); } destroy() { this.destroyed = true; this.orbiter?.abort('Conversation manager closed'); for (const client of this.subscribers) { clearInterval(client.keepAlive); if (!client.res.writableEnded && !client.res.destroyed) { client.res.end(); } } this.subscribers.clear(); } } /** @type {Map} */ const activeManagers = new Map(); /** * Get or create an active ConversationManager for a conversation. * @param {number} conversationId * @param {{ companyId: number, googleAuthId: number }} meta * @returns {ConversationManager} */ export function getOrCreateManager(conversationId, { companyId, googleAuthId }) { let manager = activeManagers.get(conversationId); if (!manager) { manager = new ConversationManager(conversationId, { companyId, googleAuthId }); activeManagers.set(conversationId, manager); } else { if (companyId) manager.companyId = companyId; if (googleAuthId) manager.googleAuthId = googleAuthId; } return manager; } /** * Get an active ConversationManager if one exists. * @param {number} conversationId * @returns {ConversationManager|undefined} */ export function getActiveManager(conversationId) { return activeManagers.get(conversationId); } /** * Close, destroy, and remove an active ConversationManager if one exists. * @param {number} conversationId */ export function removeManager(conversationId) { const manager = activeManagers.get(conversationId); if (manager) { manager.destroy(); activeManagers.delete(conversationId); } } /** * Close and destroy all active conversation managers. */ export function closeAllManagers() { for (const [id, manager] of activeManagers.entries()) { manager.destroy(); activeManagers.delete(id); } }