From e80e3273b7c0fd4ba7cc6488dd050daa3b004ff7 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 8 Oct 2026 14:32:54 -0700 Subject: [PATCH] =?UTF-8?q?[pending-judge]=20feat(DC-163):=20AI=20assistan?= =?UTF-8?q?t=20that=20works=20=E2=80=94=20LLM=20intent=20engine=20+=20fixe?= =?UTF-8?q?d=20chat=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 28 + .../__tests__/routes/ai-intent-engine.test.js | 368 ++++++++++++++ dashcaddy-api/openapi.yaml | 65 +++ dashcaddy-api/routes/ai-intent.js | 480 +++++++++++++++--- dashcaddy-api/src/app.js | 2 +- status/dist/features.js | 292 +++++------ status/js/ai-chat.js | 347 +++++++++---- status/js/app-selector.js | 22 + status/sw.js | 2 +- 9 files changed, 1313 insertions(+), 293 deletions(-) create mode 100644 dashcaddy-api/__tests__/routes/ai-intent-engine.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0bf97..2105b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **DC-163: AI assistant that actually works — LLM intent engine + fixed + chat panel.** The 🤖 floating assistant was broken end-to-end: the + frontend read a nonexistent `data.data` envelope (every reply was + "Sorry, something went wrong"), the suggestion chips had nested-quote + onclick handlers (invalid JS, dead buttons), and the Deploy button called + a function that never existed. All three fixed. The router now runs TWO + engines behind one contract: an OpenAI-compatible LLM classifier (system + prompt embeds the LIVE 78-app catalog + wizard categories; conversation + history lets "yes" / "name it vault" resolve) with the original regex + patterns as the offline default AND automatic failover (HTTP error / + timeout / junk output → pattern answers, `engine` + `llmError` tell which + served). LLM output is schema-validated against closed vocabularies — + invented template ids are nulled, illegal intents coerced to unknown, + replies sanitized (control chars stripped, bounded) — the model can never + emit an action outside the platform's real set. Key storage: PUT + /api/v1/ai/settings writes to the ENCRYPTED credential store (never + config.json — GET /config echoes the raw file to browsers); GET never + returns the key; env AI_LLM_* / LLM_PROXY_URL fallback for headless + installs; POST /ai/settings/test proves the pipeline with one sample + classify; DELETE clears. Chat panel: flat-envelope parsing, data-attribute + chips with listeners, deploy button opens the REAL deploy modal via new + window.openDeployConfigFor(id) in app-selector.js, engine badge in the + header, ⚙ settings sub-panel. "Private YouTube for my living room TV" → + jellyfin deploy card, works with or without the LLM. 20 new tests + (engine selection, failover incl. AbortError timeout, closed-vocab + enforcement, history capping, key-never-echoed, settings CRUD + test, + flat envelope contract) on top of the 18 original pattern tests. + OpenAPI: 4 AI path entries. dist bundle rebuilt. - **DC-162 rev2: "Connect your AI" — spec Streamable HTTP MCP + one-click keys.** New primary-row button 🤖 Connect AI on the dashboard opens a modal that mints a dedicated `dk_` key and returns the standard MCP Streamable-HTTP diff --git a/dashcaddy-api/__tests__/routes/ai-intent-engine.test.js b/dashcaddy-api/__tests__/routes/ai-intent-engine.test.js new file mode 100644 index 0000000..c5a4568 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/ai-intent-engine.test.js @@ -0,0 +1,368 @@ +/** + * DC-163: AI Intent Router — dual-engine tests. + * + * Covers: + * - classify() engine selection: pattern-only when unconfigured, LLM when + * credential store returns config, failover to pattern on HTTP errors / + * timeouts / junk output. + * - closed-vocabulary schema: invented template ids nulled; illegal intent + * values coerced to 'unknown'; reply sanitized + length-bounded. + * - enrich() response-shape contract the chat panel consumes. + * - HTTP surface: POST /ai/intent flat envelope, /ai/settings GET/PUT/ + * DELETE/POST-test, key never echoed, capabilities engine reporting. + */ + +'use strict'; + +const express = require('express'); +const path = require('path'); + +const intentMod = require('../../routes/ai-intent'); + +// test key value built via concat (display-layer redactor note) +const LLM_TEST_KEY = 'sk-' + 'testsecret123'; + +function fakeCredStore(payload) { + const store = new Map(); + if (payload !== undefined) store.set('ai:llm', payload); + return { + _store: store, + retrieve: jest.fn(async (k) => { + if (!store.has(k)) { throw new Error('not found'); } + return store.get(k); + }), + store: jest.fn(async (k, v) => { store.set(k, v); return { key: k, stored: true }; }), + delete: jest.fn(async (k) => { store.delete(k); return true; }), + }; +} + +function llmReplyFetch(content, calls) { + return async (url, opts) => { + if (calls) calls.push({ url, opts }); + return { + ok: true, status: 200, + json: async () => ({ choices: [{ message: { content } }] }), + }; + }; +} + +const silentLog = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + +describe('DC-163 classify engine selection', () => { + test('no credential store + no env → pattern engine, zero fetches', async () => { + let fired = 0; + const out = await intentMod.classify({ + message: 'Deploy Plex', + history: [], + deps: { credentialManager: null, env: {}, fetchImpl: async () => { fired++; return {}; }, log: silentLog, timeoutMs: 1000 }, + }); + expect(out.engine).toBe('pattern'); + expect(out.intent).toBe('deploy'); + expect(out.appId).toBe('plex'); + expect(fired).toBe(0); + }); + + test('credential store config → llm engine; off-catalog phrasing understood', async () => { + const calls = []; + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' })); + const fetchImpl = llmReplyFetch('{"intent":"deploy","appId":"jellyfin","reply":"Jellyfin it is"}', calls); + const out = await intentMod.classify({ + message: 'I want a private YouTube for my living room TV', + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 5000 }, + }); + expect(out.engine).toBe('llm'); + expect(out.intent).toBe('deploy'); + expect(out.appId).toBe('jellyfin'); // regex would have said 'unknown' + // prompt carries the real catalog + const sysPrompt = calls[0].opts.body ? JSON.parse(calls[0].opts.body).messages[0].content : ''; + expect(sysPrompt).toContain('jellyfin'); + expect(sysPrompt).toContain('radicale'); + // auth header: Bearer with the stored key (shape check, value compared) + expect(calls[0].opts.headers.Authorization).toBe(`Bearer ${LLM_TEST_KEY}`); + }); + + test('hallucinated template id is nulled (closed vocabulary)', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const out = await intentMod.classify({ + message: 'deploy YouTube Premium please', + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"youtube-premium","reply":"ok"}'), log: silentLog, timeoutMs: 5000 }, + }); + expect(out.engine).toBe('llm'); + expect(out.appId || null).toBeNull(); + }); + + test('illegal intent string coerces to unknown', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const out = await intentMod.classify({ + message: 'frobnicate the widget', + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"rm -rf","appId":null,"reply":"hmm"}'), log: silentLog, timeoutMs: 5000 }, + }); + expect(out.intent).toBe('unknown'); + }); + + test('LLM HTTP 500 → pattern failover with llmError recorded', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const fetchImpl = async () => ({ ok: false, status: 500, json: async () => ({}) }); + const out = await intentMod.classify({ + message: 'why is plex down', + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 1000 }, + }); + expect(out.engine).toBe('pattern'); + expect(out.intent).toBe('diagnose'); + expect(out.serviceId).toBe('plex'); + expect(out.llmError).toBeTruthy(); + }); + + test('LLM timeout → AbortError surfaces as timeout failover', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const fetchImpl = (url, opts) => new Promise((_res, rej) => { + opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; rej(e); }); + }); + const out = await intentMod.classify({ + message: 'Back up everything', + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 80 }, + }); + expect(out.engine).toBe('pattern'); + expect(out.intent).toBe('backup'); + expect(String(out.llmError)).toMatch(/timeout/i); + }, 10000); + + test('markdown-fenced JSON + prefix prose is repaired and parsed', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const out = await intentMod.classify({ + message: 'Block ads on my network', + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('Sure! ```json\n{"intent":"recommend","appId":null,"categories":["home-network"],"reply":"AdGuard rules"}\n```'), log: silentLog, timeoutMs: 5000 }, + }); + expect(out.engine).toBe('llm'); + expect(out.intent).toBe('recommend'); + expect(out.llmReply).toBe('AdGuard rules'); + }); + + test('reply control chars stripped and bounded', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const long = 'x'.repeat(2000) + '\u0000evil'; + const out = await intentMod.classify({ + message: 'what is deployed', + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch(JSON.stringify({ intent: 'list', reply: long })), log: silentLog, timeoutMs: 5000 }, + }); + expect(out.llmReply.length).toBeLessThanOrEqual(600); + expect(out.llmReply.indexOf('evil')).toBe(-1); + // eslint-disable-next-line no-control-regex -- deliberately asserting control chars were stripped + expect(out.llmReply).not.toMatch(new RegExp('[\\u0000-\\u001F]')); + }); + + test('history is role-filtered and capped at MAX_HISTORY', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const calls = []; + const fetchImpl = llmReplyFetch('{"intent":"deploy","appId":"gitea","reply":"ok"}', calls); + const hist = Array.from({ length: 20 }, (_, i) => ({ role: i % 3 === 2 ? 'system' : (i % 2 ? 'user' : 'assistant'), content: 'c' + i })); + await intentMod.classify({ message: 'yes deploy it', history: hist, deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 5000 } }); + const msgs = JSON.parse(calls[0].opts.body).messages; + expect(msgs.length).toBeLessThanOrEqual(8); // system + ≤6 history + user + expect(msgs.filter(m => m.role === 'system').length).toBe(1); // injected 'system' roles dropped + }); + + test('env AI_LLM_* config works without credential store', async () => { + const out = await intentMod.classify({ + message: 'Deploy Plex', + history: [], + deps: { credentialManager: null, env: { AI_LLM_BASE_URL: 'http://llm.test/v1', AI_LLM_API_KEY: '***' + 'envkey', AI_LLM_MODEL: 'env-model' }, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"uptime-kuma","reply":"sure"}'), log: silentLog, timeoutMs: 5000 }, + }); + expect(out.engine).toBe('llm'); + expect(out.model).toBe('env-model'); + expect(out.appId).toBe('uptime-kuma'); // NOT in the old regex list — catalog-wide + }); + + test('LLM disagreement: pattern said recommend, LLM says deploy → deploy response rebuilt', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const out = await intentMod.classify({ + message: 'I want to stream movies', // regex: recommend + history: [], + deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"jellyfin","reply":"Jellyfin streams movies"}'), log: silentLog, timeoutMs: 5000 }, + }); + expect(out.intent).toBe('deploy'); + expect(out.appId).toBe('jellyfin'); + const enriched = intentMod.enrich({ ...out }); + expect(enriched.response.message).toBeTruthy(); + expect(enriched.response.appId).toBe('jellyfin'); + expect(enriched.deployPlan.templateId).toBe('jellyfin'); + expect(enriched.deployPlan.body.port).toBe(8096); // real template defaultPort, not null + }); +}); + +// ─── HTTP surface ──────────────────────────────────────────────────────────── +function buildRouter({ credentialManager = fakeCredStore(undefined), env = {}, fetchImpl = null } = {}) { + jest.resetModules(); + const mod = require('../../routes/ai-intent'); + const asyncHandler = (fn) => async (req, res, next) => { + try { await fn(req, res, next); } catch (e) { next(e); } + }; + const errorResponse = (res, code, msg, extra) => res.status(code).json({ success: false, error: msg, ...(extra || {}) }); + + const app = express(); + app.use(express.json()); + app.use('/api/v1', mod({ asyncHandler, errorResponse, log: silentLog, siteConfig: null, credentialManager, fetchImpl, env })); + app.use((err, req, res, next) => { res.status(err.statusCode || 500).json({ success: false, error: err.message }); }); // eslint-disable-line no-unused-vars + return app; +} + +async function start(app) { + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + return { server, base: `http://127.0.0.1:${server.address().port}/api/v1` }; +} + +describe('DC-163 ai HTTP routes', () => { + test('POST /ai/intent — flat ok() envelope (no data.data wrapper)', async () => { + const app = buildRouter(); + const { server, base } = await start(app); + try { + const res = await fetch(`${base}/ai/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Deploy Plex' }), + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.intent).toBe('deploy'); + expect(body.engine).toBe('pattern'); + expect(body.response.message).toBeTruthy(); + expect(body.response.appId).toBe('plex'); + expect(body.deployPlan.body.name).toBe('Plex'); + expect(body.timestamp).toBeTruthy(); + } finally { server.close(); } + }); + + test('POST /ai/intent — 400 on missing/non-string message', async () => { + const app = buildRouter(); + const { server, base } = await start(app); + try { + for (const bad of [{}, { message: '' }, { message: 42 }, { message: ' '}]) { + const res = await fetch(`${base}/ai/intent`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(bad) }); + expect(res.status).toBe(400); + } + } finally { server.close(); } + }); + + test('POST /ai/intent — message over 1000 chars is truncated, not rejected', async () => { + const app = buildRouter(); + const { server, base } = await start(app); + try { + const res = await fetch(`${base}/ai/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Deploy Plex ' + 'z'.repeat(1500) }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message.length).toBeLessThanOrEqual(1000); + expect(body.intent).toBe('deploy'); + } finally { server.close(); } + }); + + test('GET /ai/settings — reports configuration WITHOUT the api key', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' })); + const app = buildRouter({ credentialManager: cm }); + const { server, base } = await start(app); + try { + const res = await fetch(`${base}/ai/settings`); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.configured).toBe(true); + expect(body.source).toBe('credential-store'); + expect(body.model).toBe('test-model'); + const raw = JSON.stringify(body); + expect(raw).not.toContain(LLM_TEST_KEY); + expect(body.hasApiKey).toBe(true); + } finally { server.close(); } + }); + + test('PUT /ai/settings — stores in credential store; requires baseUrl+apiKey on first save', async () => { + const cm = fakeCredStore(undefined); + const app = buildRouter({ credentialManager: cm }); + const { server, base } = await start(app); + try { + // missing key → 400 + const r1 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'http://llm.test/v1' }) }); + expect(r1.status).toBe(400); + // bad baseUrl → 400 + const r2 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'ftp://x', apiKey: LLM_TEST_KEY }) }); + expect(r2.status).toBe(400); + // valid → stored encrypted (credentialManager.store), response never echoes key + const r3 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'http://llm.test/v1', model: 'test-model', apiKey: LLM_TEST_KEY }) }); + const b3 = await r3.json(); + expect(r3.status).toBe(200); + expect(b3.saved).toBe(true); + expect(JSON.stringify(b3)).not.toContain(LLM_TEST_KEY); + const stored = JSON.parse(cm._store.get('ai:llm')); + expect(stored.apiKey).toBe(LLM_TEST_KEY); + // partial update keeps stored key + const r4 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'new-model' }) }); + expect(r4.status).toBe(200); + expect(JSON.parse(cm._store.get('ai:llm')).apiKey).toBe(LLM_TEST_KEY); + expect(JSON.parse(cm._store.get('ai:llm')).model).toBe('new-model'); + } finally { server.close(); } + }); + + test('DELETE /ai/settings — clears stored config', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const app = buildRouter({ credentialManager: cm }); + const { server, base } = await start(app); + try { + const res = await fetch(`${base}/ai/settings`, { method: 'DELETE' }); + expect(res.status).toBe(200); + expect(cm._store.has('ai:llm')).toBe(false); + const after = await (await fetch(`${base}/ai/settings`)).json(); + expect(after.configured).toBe(false); + } finally { server.close(); } + }); + + test('POST /ai/settings/test — end-to-end probe returns sample intent, no key', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' })); + const fetchImpl = llmReplyFetch('{"intent":"backup","appId":null,"reply":"Backup it is"}'); + const app = buildRouter({ credentialManager: cm, fetchImpl }); + const { server, base } = await start(app); + try { + const res = await fetch(`${base}/ai/settings/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.engine).toBe('llm'); + expect(body.sample.intent).toBe('backup'); + expect(JSON.stringify(body)).not.toContain(LLM_TEST_KEY); + } finally { server.close(); } + }); + + test('POST /ai/settings/test — failure degrades to ok:false with reason (never throws)', async () => { + const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY })); + const app = buildRouter({ credentialManager: cm, fetchImpl: async () => { throw new Error('ECONNREFUSED'); } }); + const { server, base } = await start(app); + try { + const res = await fetch(`${base}/ai/settings/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(false); + expect(body.reason).toMatch(/ECONNREFUSED/); + } finally { server.close(); } + }); + + test('GET /ai/capabilities — engine reflects live configuration', async () => { + const app = buildRouter(); + const { server, base } = await start(app); + try { + const body = await (await fetch(`${base}/ai/capabilities`)).json(); + expect(body.success).toBe(true); + expect(body.engine).toBe('pattern'); + expect(body.llm.configured).toBe(false); + expect(body.llm.howToEnable).toMatch(/credential store/); + expect(body.intents).toContain('deploy'); + expect(body.capabilities.length).toBeGreaterThanOrEqual(6); + } finally { server.close(); } + }); +}); diff --git a/dashcaddy-api/openapi.yaml b/dashcaddy-api/openapi.yaml index 2f4a676..aff7750 100644 --- a/dashcaddy-api/openapi.yaml +++ b/dashcaddy-api/openapi.yaml @@ -7773,6 +7773,71 @@ paths: '200': description: Probe result (ok flag inside — transport errors are reported, not thrown) + /api/v1/ai/intent: + post: + tags: [AI] + summary: Natural language → structured intent + description: > + Dual-engine intent router. LLM engine (OpenAI-compatible classifier, + configured via /ai/settings or AI_LLM_* env) resolves against the + full app catalog and conversation history; pattern engine serves + offline and as automatic failover (engine field tells which answered; + llmError records the failover reason). Output is schema-validated + against closed vocabularies — the model can never emit an action or + template id outside the platform's real sets. Response is the flat + ok() envelope: {success, intent, confidence, action, engine, appId?, + serviceId?, response{message,appId?,appName?,recommendations?, + suggestions?,disclaimer?}, deployPlan?, wizardCall?, timestamp}. + responses: + '200': + description: Structured intent (flat envelope) + '400': + description: message (non-empty string) required + + /api/v1/ai/capabilities: + get: + tags: [AI] + summary: What the assistant can do + live engine status + responses: + '200': + description: Intent list, capabilities, engine (llm|pattern), llm config status (key never included) + + /api/v1/ai/settings: + get: + tags: [AI] + summary: LLM assistant configuration (key never echoed) + responses: + '200': + description: configured, source, baseUrl, model, hasApiKey + put: + tags: [AI] + summary: Store LLM config in the encrypted credential store + description: > + body {baseUrl?, model?, apiKey?}. Stored under credential key + 'ai:llm' — NEVER config.json (GET /config returns the raw file to + the browser). Blank apiKey keeps the stored one. First save + requires baseUrl + apiKey. baseUrl must be http(s). + responses: + '200': + description: Saved (response never contains the key) + '400': + description: Invalid baseUrl or missing required fields + delete: + tags: [AI] + summary: Remove stored LLM config (falls back to env/pattern) + responses: + '200': + description: Cleared + + /api/v1/ai/settings/test: + post: + tags: [AI] + summary: One-shot classifier probe (proves the LLM end-to-end) + description: Sends a fixed sample message through the configured LLM and returns the parsed intent. Failures answer ok:false with a reason (200), never throw. + responses: + '200': + description: '{ok, engine, model?, sample?{intent,reply}} or {ok:false, reason}' + /api/v1/mcp: post: tags: [MCP] diff --git a/dashcaddy-api/routes/ai-intent.js b/dashcaddy-api/routes/ai-intent.js index f39be3a..bc9a7d8 100644 --- a/dashcaddy-api/routes/ai-intent.js +++ b/dashcaddy-api/routes/ai-intent.js @@ -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":"