The floating assistant was broken end-to-end; Sami said "Do it that works".
Three user-visible bugs fixed + the promised LLM delegation implemented:
FRONTEND (status/js/ai-chat.js — rewritten):
- Response parsing: old code read data.data; the API returns the FLAT ok()
envelope — EVERY reply rendered "Sorry, something went wrong". Now reads
the real shape (contract asserted by route test).
- Suggestion chips: onclick="...send('"Deploy Plex"')" — nested quotes,
invalid JS, dead buttons (shipped like this). Now data-driven buttons with
addEventListener.
- Deploy button: called window.openAppSelector which NEVER existed anywhere
in the tree (grep-verified). Now opens the REAL deploy modal via new
window.openDeployConfigFor(templateId) exported by app-selector.js —
resolves against the live API template cache, reuses showDeployConfig
(same path a manual catalog click takes). Widgets route to the grid.
- Conversation history (last 6 turns) now rides along per request, engine
badge in header, ⚙ settings sub-panel (baseUrl/model/API key + Test +
Remove), all server text via textContent (no HTML injection), CSP-safe
(no inline handlers).
BACKEND (routes/ai-intent.js):
- TWO engines, one contract. LLM classifier (OpenAI-compatible chat
completions) with system prompt embedding the LIVE 78-app catalog + the
six wizard categories; regex patterns kept as the zero-config default and
the automatic failover (HTTP error, timeout via AbortController,
unparseable output -> pattern answers; response carries engine +
llmError so the UI can say which served — no silent degradation).
- Closed-vocabulary schema validation: intent MUST be in the known set else
'unknown'; appId MUST exist in APP_TEMPLATES else nulled (hallucinated
ids cannot leak into deploy plans); categories filtered to the wizard's
real keys; reply stripped of control chars + bounded to 600; history
role-filtered (only user/assistant pass — client-injected system turns
dropped) + capped; input capped to 1000 chars.
- enrich() rebuilds the response contract per intent; pattern-canned
response is dropped when the LLM rerouted the intent (no stale shape);
deployPlan body.port now fills from the template's defaultPort (was
hardcoded null); deployPlan shape asserted against wizard usage.
- Key storage: PUT /ai/settings stores {baseUrl,model,apiKey} in the
ENCRYPTED credential store (key 'ai:llm') via ctx.credentialManager —
NOT config.json (GET /config echoes the raw file to the browser; that
route's shape was verified before this decision). Blank apiKey on PUT
keeps the stored one. GET /ai/settings reports configured/source/model/
hasApiKey — the key value NEVER crosses the wire (asserted). Env
fallback AI_LLM_BASE_URL/AI_LLM_API_KEY/AI_LLM_MODEL + legacy
LLM_PROXY_URL for headless installs. POST /ai/settings/test = one-shot
real classify probe returning ok/sample or ok:false/reason (never
throws). DELETE clears.
- GET /ai/capabilities now reports engine + llm status (pattern was honest
marketing before; now it can be true).
MOUNT (src/app.js): aiIntentRoutes receives siteConfig, log, fetchImpl
(ctx.fetchT — test-injectable) and credentialManager.
Docs: OpenAPI +4 AI path entries (intent, capabilities, settings, settings/test); CHANGELOG entry; dist bundle rebuilt
(openDeployConfigFor present in features.js; SW cache c62511c40d).
Tests: 20 NEW in __tests__/routes/ai-intent-engine.test.js (engine
selection w/ and w/o store, off-catalog phrasing via mock LLM,
hallucinated-id nulling, illegal intent coercion, HTTP-500 failover,
AbortError timeout failover, markdown-fence repair, reply sanitization,
history cap+role filter, env config path, intent-disagreement rebuild w/
real jellyfin port 8096, flat envelope contract, 400s, 1000-char cap,
settings GET/PUT/DELETE/test incl. key-never-echoed assertions,
capabilities engine reporting) + the 18 original pattern tests unchanged +
composition-root e2e 8 pass. Full suite: 3643 passed; only the pre-existing
pdf-parse billing family red (6 failed in documented band [5,7], patch
touches no billing file).
701 lines
29 KiB
JavaScript
701 lines
29 KiB
JavaScript
/**
|
|
* 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":"<template id or null>","serviceId":"<service name or null>",' +
|
|
'"categories":["<wizard categories>"],"reply":"<short friendly sentence>"}\n' +
|
|
'Rules:\n' +
|
|
'- intent=deploy when the user wants to install/host/launch/run a specific app (including "set it up", "yes go"). Use history to resolve pronouns.\n' +
|
|
'- appId MUST be one of the template ids below (exact id), else null. Never invent ids.\n' +
|
|
'- intent=recommend when the user states a goal without naming an app; categories from the wizard list.\n' +
|
|
'- diagnose for problems ("why is X down"), health for status questions, list for inventory questions, backup for backup requests, unknown otherwise.\n' +
|
|
'- reply: at most 160 characters, plain text, no markdown, no links.\n' +
|
|
'Template ids:\n' + tpl + '\nWizard categories:\n' + cats;
|
|
}
|
|
|
|
// ─── OpenAI-compatible chat call ───────────────────────────────────────────
|
|
async function callLlm(deps, cfg, messages) {
|
|
const doFetch = deps.fetchImpl || fetch;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), deps.timeoutMs || LLM_TIMEOUT_MS);
|
|
try {
|
|
const res = await doFetch(`${cfg.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${cfg.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: cfg.model,
|
|
messages,
|
|
temperature: 0,
|
|
max_tokens: 300,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`LLM HTTP ${res.status}`);
|
|
}
|
|
const data = await res.json();
|
|
const content = data && data.choices && data.choices[0] &&
|
|
data.choices[0].message && data.choices[0].message.content;
|
|
if (typeof content !== 'string' || !content.trim()) {
|
|
throw new Error('LLM empty completion');
|
|
}
|
|
return content;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
// ─── Parse + repair + schema-validate the LLM output ───────────────────────
|
|
function repairParsed(raw) {
|
|
// Deterministic corrections for common small-model drift BEFORE schema.
|
|
let out = raw.replace(/```(json)?/gi, '').trim();
|
|
const first = out.indexOf('{');
|
|
const last = out.lastIndexOf('}');
|
|
if (first !== -1 && last > first) out = out.slice(first, last + 1);
|
|
return out;
|
|
}
|
|
|
|
function validateParsed(parsed) {
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
let intent = typeof parsed.intent === 'string' ? parsed.intent.toLowerCase() : null;
|
|
if (!INTENTS.includes(intent)) {
|
|
// drift repair: unknown-ish values → 'unknown'
|
|
intent = 'unknown';
|
|
}
|
|
const appId = normId(parsed.appId);
|
|
const serviceId = normId(parsed.serviceId);
|
|
const categories = Array.isArray(parsed.categories)
|
|
? parsed.categories.map((c) => (typeof c === 'string' ? c.toLowerCase() : null)).filter((c) => c && RECOMMEND_CATEGORIES.includes(c))
|
|
: [];
|
|
return {
|
|
intent,
|
|
appId: appId && TEMPLATE_IDS.includes(appId) ? appId : null,
|
|
serviceId: serviceId || null,
|
|
categories,
|
|
reply: sanitizeText(parsed.reply, MAX_REPLY),
|
|
};
|
|
}
|
|
|
|
// ─── Merge validated LLM fields onto the pattern result ────────────────────
|
|
// Pattern stays the structural base (known-good shapes); LLM contributes the
|
|
// semantic understanding. appId is never LOST silently when both engines
|
|
// disagree — the LLM candidate wins if it is a valid catalog id, because the
|
|
// LLM saw the full 78-app catalog while regex knows only its hardcoded list.
|
|
function mergeIntent(patternResult, llm) {
|
|
const merged = { ...patternResult };
|
|
const intentChanged = llm.intent !== patternResult.intent;
|
|
merged.intent = llm.intent;
|
|
merged.action = ACTIONS_BY_INTENT[llm.intent] || patternResult.action || null;
|
|
// If the LLM routed to a DIFFERENT intent than the pattern did, the
|
|
// pattern's canned response object belongs to the old intent — drop it
|
|
// and let enrich() rebuild the shape for the new one (with llmReply text).
|
|
if (intentChanged) merged.response = null;
|
|
if (llm.appId) merged.appId = llm.appId;
|
|
if (llm.serviceId && !merged.serviceId) merged.serviceId = llm.serviceId;
|
|
if (llm.intent === 'recommend') {
|
|
merged.categories = (llm.categories && llm.categories.length) ? llm.categories : (patternResult.categories || ['media-streaming']);
|
|
}
|
|
if (llm.reply) merged.llmReply = llm.reply;
|
|
return merged;
|
|
}
|
|
|
|
const DEFAULT_INTENT_MESSAGES = {
|
|
deploy: 'Ready to deploy.',
|
|
recommend: 'Here are some suggestions:',
|
|
diagnose: 'Let me check what\'s going on...',
|
|
health: 'Checking system health...',
|
|
list: 'Here are your services:',
|
|
backup: 'Creating a full system backup now...',
|
|
unknown: 'I\'m not sure what you\'d like to do — try the app catalog, or rephrase.',
|
|
};
|
|
|
|
// ─── Runtime config resolution ─────────────────────────────────────────────
|
|
// Priority: encrypted credential store (self-service Settings UI, key
|
|
// 'ai:llm') → env (AI_LLM_* / LLM_PROXY_URL). NEVER config.json: its GET
|
|
// endpoint returns the raw file to the browser, so secrets must not live
|
|
// there. Env fallback keeps headless installs working without the UI.
|
|
async function getLlmConfig(deps) {
|
|
const env = deps.env || {};
|
|
let stored = null;
|
|
if (deps.credentialManager) {
|
|
try {
|
|
const raw = await deps.credentialManager.retrieve('ai:llm');
|
|
if (raw) stored = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
} catch (_) { /* not stored yet — env fallback */ }
|
|
}
|
|
const baseUrl = ((stored && stored.baseUrl) || env.AI_LLM_BASE_URL || env.LLM_PROXY_URL || '').trim();
|
|
const apiKey = ((stored && stored.apiKey) || env.AI_LLM_API_KEY || env.LLM_PROXY_API_KEY || '').trim();
|
|
const model = ((stored && stored.model) || env.AI_LLM_MODEL || 'gpt-4o-mini').trim();
|
|
if (!baseUrl || !apiKey) return null;
|
|
if (!/^https?:\/\//i.test(baseUrl)) return null;
|
|
const source = (stored && stored.baseUrl && stored.apiKey) ? 'credential-store' : 'env';
|
|
return { baseUrl, apiKey, model, source };
|
|
}
|
|
// sync alias retained for pure-callers/tests that never touch the store
|
|
function resolveLlmConfig({ env = {}, site = null } = {}) {
|
|
const ai = (site && site.ai) || {};
|
|
const llm = ai.llm || {};
|
|
const baseUrl = (llm.baseUrl || env.AI_LLM_BASE_URL || env.LLM_PROXY_URL || '').trim();
|
|
const apiKey = (llm.apiKey || env.AI_LLM_API_KEY || env.LLM_PROXY_API_KEY || '').trim();
|
|
const model = (llm.model || env.AI_LLM_MODEL || 'gpt-4o-mini').trim();
|
|
if (!baseUrl || !apiKey) return null;
|
|
if (!/^https?:\/\//i.test(baseUrl)) return null;
|
|
return { baseUrl, apiKey, model };
|
|
}
|
|
|
|
// ─── Intent Pattern Library (engine 2 — offline fallback; unchanged shapes) ─
|
|
|
|
const INTENT_PATTERNS = [
|
|
// ── Deploy intents ──
|
|
{
|
|
intent: 'deploy',
|
|
patterns: [
|
|
/\b(?:deploy|install|set up|setup|host|run|start|spin up|launch)\b.*\b(?:plex|jellyfin|emby|sonarr|radarr|nextcloud|gitea|vaultwarden|adguard|wireguard|home.assistant|grafana|prometheus|qbittorrent|transmission|portainer|redis|postgres|mariadb|mongodb|nginx)\b/i,
|
|
/\b(?:i want|i need|can you|help me|let'?s)\b.*\b(?:deploy|install|set up|host|run)\b/i,
|
|
],
|
|
action: 'dashcaddy_deploy_app',
|
|
extractApp: (msg) => {
|
|
const apps = ['plex', 'jellyfin', 'emby', 'sonarr', 'radarr', 'prowlarr',
|
|
'lidarr', 'readarr', 'qbittorrent', 'transmission', 'nextcloud',
|
|
'vaultwarden', 'gitea', 'adguard', 'pihole', 'wireguard',
|
|
'home assistant', 'homeassistant', 'grafana', 'prometheus',
|
|
'portainer', 'redis', 'postgres', 'postgresql', 'mariadb',
|
|
'mongodb', 'nginx', 'caddy', 'uptime kuma', 'code-server'];
|
|
for (const app of apps) {
|
|
if (msg.toLowerCase().includes(app)) return app.replace(/\s+/g, '-');
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
|
|
// ── Streaming/Media intents ──
|
|
{
|
|
intent: 'recommend',
|
|
patterns: [
|
|
/\b(?:stream|streaming|movie|movies|tv show|tv shows|film|films|watch|media)\b/i,
|
|
],
|
|
action: 'dashcaddy_wizard_recommend',
|
|
suggestCategories: ['media-streaming'],
|
|
response: (msg) => ({
|
|
message: 'For media streaming, I recommend:',
|
|
recommendations: [
|
|
{ app: 'plex', reason: 'Stream movies and TV shows to any device' },
|
|
{ app: 'jellyfin', reason: 'Free open-source alternative to Plex, no premium features locked' },
|
|
{ app: 'emby', reason: 'Media server with live TV and parental controls' },
|
|
{ app: 'sonarr', reason: 'Automatically download TV shows' },
|
|
{ app: 'radarr', reason: 'Automatically download movies' },
|
|
{ app: 'qbittorrent', reason: 'Download client for media files' },
|
|
],
|
|
question: 'Would you like me to deploy any of these?',
|
|
disclaimer: 'DashCaddy provides deployment tools only. Users are responsible for complying with all applicable copyright and intellectual property laws. Always stream content you own or have rights to access.',
|
|
}),
|
|
},
|
|
|
|
// ── Password manager ──
|
|
{
|
|
intent: 'recommend',
|
|
patterns: [
|
|
/\b(?:password|passwords|password manager|vaultwarden|bitwarden|1password|lastpass|secure password)\b/i,
|
|
],
|
|
action: 'dashcaddy_wizard_recommend',
|
|
suggestCategories: ['file-sync'],
|
|
response: (msg) => ({
|
|
message: 'For password management, I recommend:',
|
|
recommendations: [
|
|
{ app: 'vaultwarden', reason: 'Self-hosted Bitwarden-compatible password manager' },
|
|
],
|
|
question: 'Would you like me to deploy Vaultwarden?',
|
|
}),
|
|
},
|
|
|
|
// ── Ad blocking ──
|
|
{
|
|
intent: 'recommend',
|
|
patterns: [
|
|
/\b(?:ad block|adblock|block ads|ad blocking|pihole|adguard|dns blocking)\b/i,
|
|
],
|
|
action: 'dashcaddy_wizard_recommend',
|
|
suggestCategories: ['home-network'],
|
|
response: (msg) => ({
|
|
message: 'For network-wide ad blocking, I recommend:',
|
|
recommendations: [
|
|
{ app: 'adguard', reason: 'DNS-level ad blocking for your entire network' },
|
|
{ app: 'pihole', reason: 'Alternative DNS ad blocker with detailed statistics' },
|
|
],
|
|
question: 'Would you like me to set up ad blocking?',
|
|
}),
|
|
},
|
|
|
|
// ── File storage ──
|
|
{
|
|
intent: 'recommend',
|
|
patterns: [
|
|
/\b(?:file storage|cloud storage|google drive|dropbox|file sync|nextcloud|owncloud)\b/i,
|
|
],
|
|
action: 'dashcaddy_wizard_recommend',
|
|
suggestCategories: ['file-sync'],
|
|
response: (msg) => ({
|
|
message: 'For file storage and sync, I recommend:',
|
|
recommendations: [
|
|
{ app: 'nextcloud', reason: 'Self-hosted Google Drive replacement' },
|
|
],
|
|
question: 'Would you like me to deploy Nextcloud?',
|
|
}),
|
|
},
|
|
|
|
// ── Development ──
|
|
{
|
|
intent: 'recommend',
|
|
patterns: [
|
|
/\b(?:git|code|develop|programming|ide|vs code|github|self-hosted git)\b/i,
|
|
],
|
|
action: 'dashcaddy_wizard_recommend',
|
|
suggestCategories: ['development'],
|
|
response: (msg) => ({
|
|
message: 'For development tools, I recommend:',
|
|
recommendations: [
|
|
{ app: 'gitea', reason: 'Self-hosted Git with CI/CD pipelines' },
|
|
{ app: 'code-server', reason: 'VS Code in your browser' },
|
|
],
|
|
question: 'Would you like me to deploy any of these?',
|
|
}),
|
|
},
|
|
|
|
// ── Diagnostics ──
|
|
{
|
|
intent: 'diagnose',
|
|
patterns: [
|
|
/\b(?:why|what'?s wrong|broken|down|not working|slow|error|failing|crashed|unhealthy|diagnose|troubleshoot|debug)\b/i,
|
|
],
|
|
action: 'dashcaddy_diagnose',
|
|
extractService: (msg) => {
|
|
const match = msg.match(/(?:why is |is |)(\w+)\s+(?:down|slow|broken|not working|failing|crashed)/i);
|
|
if (match) return match[1].toLowerCase();
|
|
return null;
|
|
},
|
|
response: (msg) => ({
|
|
message: 'Let me check what\'s going on...',
|
|
action: 'diagnose',
|
|
}),
|
|
},
|
|
|
|
// ── Backup ──
|
|
{
|
|
intent: 'backup',
|
|
patterns: [
|
|
/\b(?:backup|back up|save|snapshot|export)\b/i,
|
|
],
|
|
action: 'dashcaddy_create_backup',
|
|
response: (msg) => ({
|
|
message: 'Creating a full system backup now...',
|
|
action: 'backup',
|
|
}),
|
|
},
|
|
|
|
// ── Health check ──
|
|
{
|
|
intent: 'health',
|
|
patterns: [
|
|
/\b(?:health|healthy|status|everything ok|all good|system check|how are things)\b/i,
|
|
],
|
|
action: 'dashcaddy_system_health',
|
|
response: (msg) => ({
|
|
message: 'Checking system health...',
|
|
action: 'health_check',
|
|
}),
|
|
},
|
|
|
|
// ── List/show ──
|
|
{
|
|
intent: 'list',
|
|
patterns: [
|
|
/\b(?:list|show|what.*running|what.*deployed|what.*have|what.*services)\b/i,
|
|
],
|
|
action: 'dashcaddy_list_services',
|
|
response: (msg) => ({
|
|
message: 'Here are your services:',
|
|
action: 'list_services',
|
|
}),
|
|
},
|
|
];
|
|
|
|
// ─── Pattern engine (pure, sync — the public routeIntent contract) ─────────
|
|
|
|
function routeIntent(message) {
|
|
for (const intent of INTENT_PATTERNS) {
|
|
for (const pattern of intent.patterns) {
|
|
if (pattern.test(message)) {
|
|
const result = {
|
|
intent: intent.intent,
|
|
confidence: 0.85,
|
|
action: intent.action,
|
|
message: message,
|
|
response: typeof intent.response === 'function' ? intent.response(message) : null,
|
|
};
|
|
|
|
if (intent.extractApp) {
|
|
const app = intent.extractApp(message);
|
|
if (app) result.appId = app;
|
|
}
|
|
if (intent.extractService) {
|
|
const service = intent.extractService(message);
|
|
if (service) result.serviceId = service;
|
|
}
|
|
if (intent.suggestCategories) {
|
|
result.categories = intent.suggestCategories;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
intent: 'unknown',
|
|
confidence: 0.3,
|
|
message,
|
|
response: {
|
|
message: 'I\'m not sure what you\'d like to do. Here are some things I can help with:',
|
|
suggestions: [
|
|
'Deploy an app: "Deploy Plex" or "Set up Nextcloud"',
|
|
'Get recommendations: "I want to stream movies" or "Block ads on my network"',
|
|
'Check status: "Is everything OK?" or "Why is Plex down?"',
|
|
'Browse catalog: "What can I self-host?"',
|
|
'Create backup: "Back up everything"',
|
|
],
|
|
action: 'suggest',
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── Enrichment shared by both engines (plans, wizard calls, reply merge) ──
|
|
function enrich(result) {
|
|
if (result.intent === 'deploy' && result.appId) {
|
|
const tpl = APP_TEMPLATES[result.appId];
|
|
result.appName = (tpl && tpl.name) || result.appId;
|
|
result.deployPlan = {
|
|
templateId: result.appId,
|
|
endpoint: 'POST /api/v1/discover/adopt',
|
|
body: {
|
|
containerId: null, // set after container creation
|
|
serviceId: result.appId,
|
|
name: result.appName,
|
|
port: (tpl && tpl.defaultPort) || null, // from template when known
|
|
generateDns: true,
|
|
generateRoute: true,
|
|
},
|
|
nextSteps: [
|
|
`Search catalog: GET /api/v1/catalog/search?q=${encodeURIComponent(result.appId)}`,
|
|
`Get template: GET /api/v1/catalog/${encodeURIComponent(result.appId)}`,
|
|
'Deploy: POST /api/v1/discover/adopt',
|
|
],
|
|
};
|
|
const baseMsg = result.response && result.response.message
|
|
? result.response.message
|
|
: (result.llmReply || DEFAULT_INTENT_MESSAGES.deploy);
|
|
result.response = { ...(result.response || {}), message: baseMsg, appId: result.appId, appName: result.appName };
|
|
} else if (result.intent === 'recommend' && result.categories) {
|
|
result.wizardCall = {
|
|
endpoint: 'POST /api/v1/wizard/recommend',
|
|
body: { categories: result.categories, hardwareProfile: 'medium' },
|
|
};
|
|
// pattern canned recs stay (they're curated); LLM reply rides along as
|
|
// llmReply for the UI's secondary line, and a message exists either way.
|
|
result.response = { ...(result.response || {}), message: (result.response && result.response.message) || result.llmReply || DEFAULT_INTENT_MESSAGES.recommend };
|
|
if (result.llmReply) result.response.llmReply = result.llmReply;
|
|
} else if (result.intent !== 'deploy') {
|
|
const baseMsg = (result.response && result.response.message)
|
|
? result.response.message
|
|
: (result.llmReply || DEFAULT_INTENT_MESSAGES[result.intent] || DEFAULT_INTENT_MESSAGES.unknown);
|
|
result.response = { ...(result.response || {}), message: baseMsg, suggestions: (result.response && result.response.suggestions) || undefined };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ─── Classification orchestration ──────────────────────────────────────────
|
|
async function classify({ message, history, deps }) {
|
|
const pattern = routeIntent(message);
|
|
const cfg = await getLlmConfig(deps);
|
|
if (!cfg) {
|
|
return { ...pattern, engine: 'pattern' };
|
|
}
|
|
|
|
const safeHistory = (Array.isArray(history) ? history : [])
|
|
.filter((h) => h && (h.role === 'user' || h.role === 'assistant') && typeof h.content === 'string')
|
|
.slice(-MAX_HISTORY);
|
|
const messages = [
|
|
{ role: 'system', content: buildSystemPrompt() },
|
|
...safeHistory,
|
|
{ role: 'user', content: message },
|
|
];
|
|
|
|
try {
|
|
const content = await callLlm(deps, cfg, messages);
|
|
const parsed = JSON.parse(repairParsed(content));
|
|
const validated = validateParsed(parsed);
|
|
if (!validated) throw new Error('LLM output failed schema validation');
|
|
deps.log.info('ai-intent', 'llm classified', { intent: validated.intent, appId: validated.appId });
|
|
const merged = mergeIntent(pattern, validated);
|
|
return { ...merged, engine: 'llm', model: cfg.model };
|
|
} catch (e) {
|
|
const reason = (e && e.name === 'AbortError') ? 'timeout' : (e && e.message) || 'unknown';
|
|
deps.log.warn('ai-intent', 'llm unavailable — pattern fallback', { reason: String(reason).slice(0, 200) });
|
|
return { ...pattern, engine: 'pattern', llmError: String(reason).slice(0, 200) };
|
|
}
|
|
}
|
|
|
|
// ─── Express Route ──────────────────────────────────────────────────────────
|
|
|
|
module.exports = function ({ asyncHandler, siteConfig = null, log = console, fetchImpl = null, env = process.env, credentialManager = null }) {
|
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
|
const router = express.Router();
|
|
const deps = {
|
|
siteConfig,
|
|
credentialManager,
|
|
log: (log && log.info && log.warn) ? log : { info: () => {}, warn: () => {}, error: () => {} },
|
|
fetchImpl,
|
|
env,
|
|
timeoutMs: Number(env.AI_LLM_TIMEOUT_MS) || LLM_TIMEOUT_MS,
|
|
};
|
|
|
|
/**
|
|
* POST /api/v1/ai/intent — natural language → structured action plan
|
|
*/
|
|
router.post('/ai/intent', wrap(async (req, res) => {
|
|
const { message, context = {} } = req.body || {};
|
|
let history = Array.isArray(req.body && req.body.history) ? req.body.history : [];
|
|
|
|
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
return errorResponse(res, 400, 'message (string) is required');
|
|
}
|
|
const cleanMessage = message.trim().slice(0, MAX_INPUT);
|
|
|
|
// History: keep only role/content string pairs from the client (the
|
|
// panel's own transcript), bounded and sanitized. Prompt-injection is
|
|
// contained by the closed-vocabulary schema, not by trusting this data.
|
|
history = history
|
|
.filter((h) => h && (h.role === 'user' || h.role === 'assistant') && typeof h.content === 'string')
|
|
.slice(-MAX_HISTORY)
|
|
.map((h) => ({ role: h.role, content: sanitizeText(h.content, 400) }));
|
|
|
|
const result = await classify({ message: cleanMessage, history, deps });
|
|
result.context = context;
|
|
result.timestamp = new Date().toISOString();
|
|
enrich(result);
|
|
|
|
ok(res, result);
|
|
}));
|
|
|
|
/**
|
|
* GET /api/v1/ai/settings — LLM assistant configuration (key never echoed)
|
|
*/
|
|
router.get('/ai/settings', wrap(async (req, res) => {
|
|
const cfg = await getLlmConfig(deps);
|
|
ok(res, {
|
|
configured: Boolean(cfg),
|
|
source: cfg ? cfg.source : null,
|
|
baseUrl: cfg ? cfg.baseUrl : null,
|
|
model: cfg ? cfg.model : null,
|
|
hasApiKey: Boolean(cfg && cfg.apiKey),
|
|
});
|
|
}));
|
|
|
|
/**
|
|
* PUT /api/v1/ai/settings — store LLM assistant config in the ENCRYPTED
|
|
* credential store (key 'ai:llm'). Never config.json — GET /config returns
|
|
* the raw file to the browser. body: { baseUrl?, model?, apiKey? }; an
|
|
* empty apiKey string means "keep the stored one".
|
|
*/
|
|
router.put('/ai/settings', wrap(async (req, res) => {
|
|
if (!deps.credentialManager) {
|
|
return errorResponse(res, 503, 'Credential store unavailable — set AI_LLM_BASE_URL/AI_LLM_API_KEY env vars instead');
|
|
}
|
|
const { baseUrl = '', model = '', apiKey = '' } = req.body || {};
|
|
if (baseUrl && !/^https?:\/\//i.test(String(baseUrl).trim())) {
|
|
return errorResponse(res, 400, 'baseUrl must be an http(s) URL of an OpenAI-compatible endpoint');
|
|
}
|
|
let stored = null;
|
|
try {
|
|
const raw = await deps.credentialManager.retrieve('ai:llm');
|
|
if (raw) stored = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
} catch (_) { /* fresh config */ }
|
|
stored = stored || {};
|
|
if (baseUrl) stored.baseUrl = String(baseUrl).trim();
|
|
if (model) stored.model = String(model).trim();
|
|
if (String(apiKey).trim()) stored.apiKey = String(apiKey).trim();
|
|
if (!stored.baseUrl || !stored.apiKey) {
|
|
return errorResponse(res, 400, 'baseUrl and apiKey are both required to enable the LLM engine (or clear with DELETE)');
|
|
}
|
|
await deps.credentialManager.store('ai:llm', JSON.stringify(stored), { label: 'AI assistant LLM config' });
|
|
log.info('ai-intent', 'LLM config saved', { model: stored.model });
|
|
ok(res, { saved: true, baseUrl: stored.baseUrl, model: stored.model, hasApiKey: true });
|
|
}));
|
|
|
|
/**
|
|
* DELETE /api/v1/ai/settings — remove stored config (falls back to env/pattern)
|
|
*/
|
|
router.delete('/ai/settings', wrap(async (req, res) => {
|
|
if (!deps.credentialManager) return errorResponse(res, 503, 'Credential store unavailable');
|
|
try { await deps.credentialManager.delete('ai:llm'); } catch (_) { /* already gone */ }
|
|
ok(res, { cleared: true });
|
|
}));
|
|
|
|
/**
|
|
* POST /api/v1/ai/settings/test — one-shot classify probe proving the LLM
|
|
* works end-to-end (returns engine + sample intent, never the key).
|
|
*/
|
|
router.post('/ai/settings/test', wrap(async (req, res) => {
|
|
const cfg = await getLlmConfig(deps);
|
|
if (!cfg) return ok(res, { ok: false, engine: 'pattern', reason: 'No LLM configured (store or env)' });
|
|
try {
|
|
const content = await callLlm(deps, cfg, [
|
|
{ role: 'system', content: buildSystemPrompt() },
|
|
{ role: 'user', content: 'Back up everything please' },
|
|
]);
|
|
const parsed = JSON.parse(repairParsed(content));
|
|
const validated = validateParsed(parsed);
|
|
if (!validated || !validated.intent) throw new Error('invalid classifier output');
|
|
return ok(res, { ok: true, engine: 'llm', model: cfg.model, sample: { intent: validated.intent, reply: validated.reply } });
|
|
} catch (e) {
|
|
const reason = String((e && (e.name === 'AbortError' ? 'timeout' : e.message)) || 'error').slice(0, 200);
|
|
log.warn('ai-intent', 'LLM config test failed', { reason });
|
|
return ok(res, { ok: false, engine: 'llm-error', reason });
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* GET /api/v1/ai/capabilities — what the AI can do + which engine is live
|
|
*/
|
|
router.get('/ai/capabilities', wrap(async (req, res) => {
|
|
const cfg = await getLlmConfig(deps);
|
|
ok(res, {
|
|
intents: INTENTS.filter((i) => i !== 'unknown'),
|
|
capabilities: [
|
|
{ name: 'deploy', description: 'Deploy self-hosted applications from the catalog (all 78 templates)' },
|
|
{ name: 'recommend', description: 'Get service recommendations based on goals' },
|
|
{ name: 'diagnose', description: 'Troubleshoot service issues' },
|
|
{ name: 'backup', description: 'Create full system backups' },
|
|
{ name: 'health', description: 'Check system and service health' },
|
|
{ name: 'list', description: 'List services and containers' },
|
|
],
|
|
tools: '17 MCP tools available via MCP protocol at /api/v1/mcp',
|
|
engine: cfg ? 'llm' : 'pattern',
|
|
llm: cfg ? { model: cfg.model, configured: true, source: cfg.source } : { configured: false, howToEnable: 'Dashboard → AI Assistant panel → ⚙ Settings (stored in the encrypted credential store), or env AI_LLM_BASE_URL + AI_LLM_API_KEY + AI_LLM_MODEL' },
|
|
exampleQueries: [
|
|
'Deploy Plex',
|
|
'I want a private YouTube for my TV',
|
|
'Block ads on my network',
|
|
'Why is Plex down?',
|
|
'Back up everything',
|
|
'What services am I running?',
|
|
],
|
|
});
|
|
}));
|
|
|
|
return router;
|
|
};
|
|
|
|
// ─── Pure exports (unit-test surface) ───────────────────────────────────────
|
|
module.exports.routeIntent = routeIntent;
|
|
module.exports.classify = classify;
|
|
module.exports.enrich = enrich;
|
|
module.exports.mergeIntent = mergeIntent;
|
|
module.exports.validateParsed = validateParsed;
|
|
module.exports.repairParsed = repairParsed;
|
|
module.exports.buildSystemPrompt = buildSystemPrompt;
|
|
module.exports.sanitizeText = sanitizeText;
|
|
module.exports.resolveLlmConfig = resolveLlmConfig;
|
|
module.exports.INTENTS = INTENTS;
|
|
module.exports.TEMPLATE_IDS = TEMPLATE_IDS;
|
|
module.exports.MAX_INPUT = MAX_INPUT;
|