[pending-judge] feat(DC-163): AI assistant that works — LLM intent engine + fixed chat panel
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).
This commit is contained in:
9 files changed
+1313
-293
No files matched your search
@@ -5,18 +5,235 @@
|
||||
* that can be executed against the DashCaddy API.
|
||||
*
|
||||
* POST /api/v1/ai/intent
|
||||
* Body: { message: "I want to stream movies", context: {} }
|
||||
* Returns: { intent, confidence, actions, followup }
|
||||
* Body: { message: "I want to stream movies", history?: [{role,content}] }
|
||||
* Returns: { intent, confidence, action, engine, response, appId?, … }
|
||||
*
|
||||
* The intent router uses pattern matching (not an LLM call) so it works
|
||||
* instantly and offline. For complex queries, it can delegate to an
|
||||
* external LLM via the LLM_PROXY_URL env var.
|
||||
* 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');
|
||||
|
||||
// ─── Intent Pattern Library ─────────────────────────────────────────────────
|
||||
// ─── 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 ──
|
||||
@@ -142,7 +359,6 @@ const INTENT_PATTERNS = [
|
||||
],
|
||||
action: 'dashcaddy_diagnose',
|
||||
extractService: (msg) => {
|
||||
// Try to extract service name from "why is X down" patterns
|
||||
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;
|
||||
@@ -193,12 +409,9 @@ const INTENT_PATTERNS = [
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Intent Router ──────────────────────────────────────────────────────────
|
||||
// ─── Pattern engine (pure, sync — the public routeIntent contract) ─────────
|
||||
|
||||
function routeIntent(message) {
|
||||
const msg = message.toLowerCase().trim();
|
||||
|
||||
// Try each intent pattern
|
||||
for (const intent of INTENT_PATTERNS) {
|
||||
for (const pattern of intent.patterns) {
|
||||
if (pattern.test(message)) {
|
||||
@@ -210,19 +423,14 @@ function routeIntent(message) {
|
||||
response: typeof intent.response === 'function' ? intent.response(message) : null,
|
||||
};
|
||||
|
||||
// Extract app name for deploy intents
|
||||
if (intent.extractApp) {
|
||||
const app = intent.extractApp(message);
|
||||
if (app) result.appId = app;
|
||||
}
|
||||
|
||||
// Extract service name for diagnose intents
|
||||
if (intent.extractService) {
|
||||
const service = intent.extractService(message);
|
||||
if (service) result.serviceId = service;
|
||||
}
|
||||
|
||||
// Suggest categories for recommend intents
|
||||
if (intent.suggestCategories) {
|
||||
result.categories = intent.suggestCategories;
|
||||
}
|
||||
@@ -232,7 +440,6 @@ function routeIntent(message) {
|
||||
}
|
||||
}
|
||||
|
||||
// No match — return a fallback that suggests using the catalog
|
||||
return {
|
||||
intent: 'unknown',
|
||||
confidence: 0.3,
|
||||
@@ -251,81 +458,222 @@ function routeIntent(message) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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 }) {
|
||||
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
|
||||
* 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') {
|
||||
if (!message || typeof message !== 'string' || !message.trim()) {
|
||||
return errorResponse(res, 400, 'message (string) is required');
|
||||
}
|
||||
const cleanMessage = message.trim().slice(0, MAX_INPUT);
|
||||
|
||||
const result = routeIntent(message);
|
||||
// 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) }));
|
||||
|
||||
// Add context from the request
|
||||
const result = await classify({ message: cleanMessage, history, deps });
|
||||
result.context = context;
|
||||
result.timestamp = new Date().toISOString();
|
||||
|
||||
// For deploy intents with an appId, include the deploy plan
|
||||
if (result.intent === 'deploy' && result.appId) {
|
||||
result.deployPlan = {
|
||||
templateId: result.appId,
|
||||
endpoint: 'POST /api/v1/discover/adopt',
|
||||
body: {
|
||||
containerId: null, // Will be set after container creation
|
||||
serviceId: result.appId,
|
||||
name: result.appId.charAt(0).toUpperCase() + result.appId.slice(1),
|
||||
port: null, // Will be set from template
|
||||
generateDns: true,
|
||||
generateRoute: true,
|
||||
},
|
||||
nextSteps: [
|
||||
`Search catalog: GET /api/v1/catalog/search?q=${result.appId}`,
|
||||
`Get template: GET /api/v1/catalog/${result.appId}`,
|
||||
`Deploy: POST /api/v1/discover/adopt`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// For recommend intents, include the wizard endpoint
|
||||
if (result.intent === 'recommend' && result.categories) {
|
||||
result.wizardCall = {
|
||||
endpoint: 'POST /api/v1/wizard/recommend',
|
||||
body: { categories: result.categories, hardwareProfile: 'medium' },
|
||||
};
|
||||
}
|
||||
enrich(result);
|
||||
|
||||
ok(res, result);
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/v1/ai/capabilities
|
||||
* Returns what the AI can do — useful for agent self-discovery
|
||||
* 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: [...new Set(INTENT_PATTERNS.map(p => p.intent))],
|
||||
intents: INTENTS.filter((i) => i !== 'unknown'),
|
||||
capabilities: [
|
||||
{ name: 'deploy', description: 'Deploy self-hosted applications from the catalog' },
|
||||
{ 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 src/mcp/mcp-server.js',
|
||||
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 to stream movies',
|
||||
'I want a private YouTube for my TV',
|
||||
'Block ads on my network',
|
||||
'Why is Plex down?',
|
||||
'Back up everything',
|
||||
@@ -337,4 +685,16 @@ module.exports = function({ asyncHandler }) {
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports.routeIntent = routeIntent;
|
||||
// ─── 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;
|
||||
Reference in new issue
Block a user