[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:
Hermes committed 2026-10-08 14:42:31 -07:00
1 parent 2aa1247a94
commit e80e3273b7
9 files changed
+1313 -293

No files matched your search

@@ -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(); }
});
});
+65
View File
@@ -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]
+420 -60
View File
@@ -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;
+1 -1
View File
@@ -1022,7 +1022,7 @@ async function createApp() {
// Log Insights — plain English activity summary + safe log disposal
apiRouter.use('/disk-settings', diskSettingsRoutes);
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler }));
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler, siteConfig: ctx.siteConfig, log: ctx.log, credentialManager: ctx.credentialManager }));
apiRouter.use(logInsightsRoutes({
asyncHandler: ctx.asyncHandler,
ok: ctx.ok,