/** * DashCaddy AI Intent Router * * Takes natural language input and returns structured, actionable intents * that can be executed against the DashCaddy API. * * POST /api/v1/ai/intent * Body: { message: "I want to stream movies", history?: [{role,content}] } * Returns: { intent, confidence, action, engine, response, appId?, … } * * Two engines, one contract: * 1. LLM engine (default when configured): a small OpenAI-compatible model * classifies the message into the DashCaddy intent vocabulary with the * LIVE app catalog + wizard categories as the candidate list — so any * of the 78 templates (and follow-ups like "name it vault") work, * including ones the old regex list never knew about. * Configured at PUT /api/v1/ai/settings (encrypted credential store, * key 'ai:llm') with env fallbacks AI_LLM_* / LLM_PROXY_URL. NEVER * config.json: GET /config returns the raw file to the browser. * The upstream call uses the GLOBAL fetch with an AbortController — * deliberately NOT ctx.fetchT, whose https path sets * rejectUnauthorized:false for self-signed .sami certs; an EXTERNAL * LLM API must get full TLS verification. * 2. Pattern engine (instant, offline, zero-config): the original regex * library. Used when no LLM is configured, and as the failover when the * LLM call fails, times out, or returns an unrepairable shape. * The response carries `engine` so callers can show what actually served. * * Injection-safety model (DC-163): the LLM is given data-finding tasks only. * Output is schema-validated against closed vocabularies; free text is * bounded and stripped of control chars. The model can NEVER produce an * action string outside the known action set or a template id outside the * catalog. The reply text is displayed client-side via textContent only. * No LLM output is ever executed. */ 'use strict'; const express = require('express'); const { ok, errorResponse } = require('../src/utils/responses'); const { APP_TEMPLATES } = require('../src/docker/app-templates'); // ─── Vocabularies (closed sets — the LLM may only choose from these) ─────── const INTENTS = ['deploy', 'recommend', 'diagnose', 'health', 'list', 'backup', 'unknown']; const ACTIONS_BY_INTENT = { deploy: 'dashcaddy_deploy_app', recommend: 'dashcaddy_wizard_recommend', diagnose: 'dashcaddy_diagnose', health: 'dashcaddy_system_health', list: 'dashcaddy_list_services', backup: 'dashcaddy_create_backup', unknown: null, }; const RECOMMEND_CATEGORIES = ['media-streaming', 'file-sync', 'home-network', 'smart-home', 'development', 'monitoring']; const TEMPLATE_IDS = Object.keys(APP_TEMPLATES); const MAX_INPUT = 1000; // user message cap (chars) const MAX_REPLY = 600; // LLM reply cap (chars) const MAX_HISTORY = 6; // prior turns sent to the LLM const LLM_TIMEOUT_MS = 8000; // per-call deadline // ─── Text sanitizer: strip control chars; no HTML filtering needed because // the chat panel renders replies with textContent only. ───────────────── function sanitizeText(s, max = MAX_REPLY) { if (typeof s !== 'string') return ''; // eslint-disable-next-line no-control-regex return s.replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max); } function normId(v) { return typeof v === 'string' ? v.toLowerCase().replace(/[\s_]+/g, '-') : null; } // ─── Candidate catalog for the LLM prompt ────────────────────────────────── function catalogBlock(limit = 80) { const tpl = TEMPLATE_IDS.slice(0, limit).map((id) => { const t = APP_TEMPLATES[id]; return `- ${id}: ${t.name || id} (${(t.description || '').split(/[.!]/)[0].slice(0, 90)})`; }).join('\n'); const cats = RECOMMEND_CATEGORIES.map((c) => `- ${c}`).join('\n'); return { tpl, cats }; } function buildSystemPrompt() { const { tpl, cats } = catalogBlock(); return 'You are the intent classifier for DashCaddy, a self-hosting dashboard. ' + 'Read the user message (and conversation history) and output ONLY a minified JSON object, no markdown:\n' + '{"intent":"deploy|recommend|diagnose|health|list|backup|unknown",' + '"appId":"