Files
dashcaddy/status/js/ai-chat.js
T
Hermes e80e3273b7 [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).
2026-10-08 14:42:31 -07:00

351 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// AI Chat Panel — DashCaddy AI Intent Router frontend (DC-163)
//
// Fixes over the previous version:
// - Response parsing: the API returns a FLAT ok() envelope
// ({success:true, intent, response, appId, engine…}); the old code read
// data.data (nonexistent) so EVERY reply showed "Sorry, something went
// wrong". Now reads the real shape.
// - Suggestion chips: old inline onclick used nested double quotes —
// invalid JS, chips did nothing. Now data-attributes + listeners.
// - Deploy button: window.openAppSelector never existed; now calls
// window.openDeployConfigFor(id) added to app-selector.js (opens the
// real deploy modal, same flow as clicking the app in the catalog).
// - Conversation history (last turns) rides along so the LLM engine can
// resolve "yes" / "name it vault" / follow-ups.
// - Engine indicator: header shows live status from /ai/capabilities.
// - ⚙ Settings: configure the LLM (OpenAI-compatible baseUrl/model/key)
// stored server-side in the encrypted credential store; Test button
// proves it end-to-end. Keys never come back from the server.
// All server/model text renders via textContent (no HTML injection).
(function () {
let chatTurns = []; // [{role, content}] — transcript for LLM context
let engineInfo = { engine: 'pattern', llmConfigured: false };
const SUGGESTIONS = [
['Deploy Plex', 'plex'],
['Stream movies', 'I want to stream movies'],
['Block ads', 'Block ads on my network'],
['System health', 'Is everything OK?'],
];
function css() {
if (document.getElementById('ai-chat-css')) return;
const s = document.createElement('style');
s.id = 'ai-chat-css';
s.textContent = `
#ai-chat-panel .ai-chip { background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc; }
#ai-chat-panel .ai-chip:hover { background:rgba(99,102,241,0.28); }
#ai-chat-panel .ai-act { display:block; margin:4px 0; border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; }
#ai-chat-panel .ai-act.deploy { background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); color:#4ade80; }
#ai-chat-panel .ai-act.warn { background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.3); color:#fbbf24; }
#ai-chat-panel .ai-act.blue { background:rgba(99,102,241,0.12); border:1px solid rgba(99,102,241,0.3); color:#a5b4fc; }
#ai-chat-panel .ai-msg-user { align-self:flex-end; background:rgba(99,102,241,0.15); border-radius:10px 10px 2px 10px; padding:10px 14px; max-width:85%; font-size:0.85rem; }
#ai-chat-panel .ai-msg-ai { align-self:flex-start; background:rgba(255,255,255,0.05); border-radius:10px 10px 10px 2px; padding:10px 14px; max-width:85%; font-size:0.85rem; }
#ai-chat-panel .ai-note { font-size:0.7rem; color:var(--text-muted,#666); padding:4px 8px; line-height:1.4; }
#ai-chat-panel input.ai-in { flex:1; background:var(--bg-input,#0f0f1a); border:1px solid var(--border,#333); border-radius:8px; padding:10px 14px; color:var(--text,#fff); font-size:0.88rem; outline:none; }
`;
document.head.appendChild(s);
}
function esc(s) { return String(s == null ? '' : s); }
function createChatPanel() {
const existing = document.getElementById('ai-chat-panel');
if (existing) existing.remove();
const panel = document.createElement('div');
panel.id = 'ai-chat-panel';
panel.style.cssText = 'position: fixed; bottom: 80px; right: 20px; width: 400px; max-width: 95vw; height: 520px; max-height: 70vh; background: var(--bg-card, #1a1a2e); border: 1px solid var(--border, #333); border-radius: 12px; display: none; flex-direction: column; z-index: 9999; box-shadow: 0 8px 32px rgba(0,0,0,0.4);';
const header = document.createElement('div');
header.style.cssText = 'padding: 14px 18px; border-bottom: 1px solid var(--border,#333); display: flex; align-items: center; gap: 10px;';
const icon = document.createElement('span');
icon.style.cssText = 'font-size:1.3rem;';
icon.textContent = '🤖';
const titles = document.createElement('div');
titles.style.cssText = 'flex:1;';
const t1 = document.createElement('div');
t1.style.cssText = 'font-weight: 700; font-size: 0.95rem;';
t1.textContent = 'AI Assistant';
const t2 = document.createElement('div');
t2.id = 'ai-status';
t2.style.cssText = 'font-size: 0.72rem; color: var(--text-muted,#666);';
t2.textContent = 'Loading…';
titles.appendChild(t1); titles.appendChild(t2);
const gear = document.createElement('button');
gear.id = 'ai-settings-btn';
gear.style.cssText = 'background:none;border:none;font-size:1.1rem;cursor:pointer;color:var(--text-muted,#888);';
gear.title = 'Assistant settings';
gear.textContent = '⚙';
const close = document.createElement('button');
close.style.cssText = 'background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-muted,#888);';
close.title = 'Close';
close.textContent = '×';
close.addEventListener('click', () => { panel.style.display = 'none'; });
header.appendChild(icon); header.appendChild(titles); header.appendChild(gear); header.appendChild(close);
const messages = document.createElement('div');
messages.id = 'ai-chat-messages';
messages.style.cssText = 'flex:1; overflow-y:auto; padding:14px; display:flex; flex-direction:column; gap:10px;';
const intro = document.createElement('div');
intro.style.cssText = 'background:rgba(99,102,241,0.08); border-radius:10px; padding:12px; font-size:0.85rem; color:var(--text-muted,#aaa);';
intro.textContent = '👋 Hi! I can help you deploy apps, diagnose issues, and manage your server. Try:';
const chips = document.createElement('div');
chips.style.cssText = 'margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;';
SUGGESTIONS.forEach(([label, text]) => {
const b = document.createElement('button');
b.className = 'ai-chip';
b.textContent = label;
b.addEventListener('click', () => send(text));
chips.appendChild(b);
});
intro.appendChild(chips);
messages.appendChild(intro);
const foot = document.createElement('div');
foot.style.cssText = 'padding:12px; border-top:1px solid var(--border,#333);';
const row = document.createElement('div');
row.style.cssText = 'display:flex; gap:8px;';
const input = document.createElement('input');
input.id = 'ai-chat-input';
input.type = 'text';
input.placeholder = 'Ask me anything...';
input.className = 'ai-in';
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
const go = document.createElement('button');
go.style.cssText = 'background:#6366f1; border:none; border-radius:8px; padding:0 16px; color:#fff; cursor:pointer; font-weight:600;';
go.textContent = '→';
go.addEventListener('click', () => send());
row.appendChild(input); row.appendChild(go);
foot.appendChild(row);
panel.appendChild(header); panel.appendChild(messages); panel.appendChild(foot);
document.body.appendChild(panel);
}
function toggle() {
let panel = document.getElementById('ai-chat-panel');
if (!panel) { css(); createChatPanel(); panel = document.getElementById('ai-chat-panel'); refreshEngineBadge(); }
panel.style.display = panel.style.display === 'flex' ? 'none' : 'flex';
if (panel.style.display === 'flex') document.getElementById('ai-chat-input').focus();
}
async function refreshEngineBadge() {
try {
const res = await fetch('/api/v1/ai/capabilities');
const data = await res.json();
if (data && data.success) {
engineInfo = { engine: data.engine, llmConfigured: Boolean(data.llm && data.llm.configured) };
const el = document.getElementById('ai-status');
if (el) el.textContent = engineInfo.llmConfigured
? 'LLM engine: ' + (data.llm.model || 'configured')
: 'Pattern engine (offline) — ⚙ to add an LLM';
}
} catch (_) { /* badge stays */ }
}
function addMessage(role, text, extra) {
const container = document.getElementById('ai-chat-messages');
if (!container) return;
const div = document.createElement('div');
div.className = role === 'user' ? 'ai-msg-user' : 'ai-msg-ai';
div.textContent = text;
container.appendChild(div);
chatTurns.push({ role: role === 'user' ? 'user' : 'assistant', content: text });
if (chatTurns.length > 12) chatTurns = chatTurns.slice(-12);
if (extra) {
const extraDiv = document.createElement('div');
extraDiv.style.cssText = 'align-self:flex-start; max-width:85%; margin-top:4px;';
if (extra.engine === 'llm-error' || extra.llmError) {
const n = document.createElement('div');
n.className = 'ai-note';
n.textContent = '⚠ LLM unavailable — answered with the offline pattern engine.';
extraDiv.appendChild(n);
}
if (extra.disclaimer) {
const disc = document.createElement('div');
disc.className = 'ai-note';
disc.textContent = '⚠ ' + esc(extra.disclaimer);
extraDiv.appendChild(disc);
}
if (Array.isArray(extra.recommendations)) {
extra.recommendations.forEach((rec) => {
const btn = document.createElement('button');
btn.className = 'ai-act deploy';
btn.textContent = `📦 Deploy ${esc(rec.app)} — ${esc(rec.reason || '')}`;
btn.addEventListener('click', () => send('Deploy ' + rec.app));
extraDiv.appendChild(btn);
});
}
if (extra.appId) {
const btn = document.createElement('button');
btn.className = 'ai-act deploy';
btn.textContent = `🚀 Deploy ${esc(extra.appName || extra.appId)}`;
btn.addEventListener('click', () => deploy(extra.appId));
extraDiv.appendChild(btn);
}
if (extra.action === 'diagnose') {
const btn = document.createElement('button');
btn.className = 'ai-act warn';
btn.textContent = '🔍 Run Diagnostics';
btn.addEventListener('click', () => { window.location.href = '#health'; });
extraDiv.appendChild(btn);
}
if (extra.action === 'backup') {
const btn = document.createElement('button');
btn.className = 'ai-act blue';
btn.textContent = '💾 Create Backup';
btn.addEventListener('click', () => { if (window.createBackup) window.createBackup(); });
extraDiv.appendChild(btn);
}
if (Array.isArray(extra.suggestions)) {
const list = document.createElement('div');
list.className = 'ai-note';
list.textContent = 'Try: ' + extra.suggestions.join(' · ');
extraDiv.appendChild(list);
}
container.appendChild(extraDiv);
}
container.scrollTop = container.scrollHeight;
}
async function send(prefilled) {
const input = document.getElementById('ai-chat-input');
const raw = prefilled || (input && input.value.trim()) || '';
if (!raw) return;
if (input) input.value = '';
addMessage('user', raw);
const container = document.getElementById('ai-chat-messages');
const typing = document.createElement('div');
typing.style.cssText = 'align-self:flex-start; color:var(--text-muted,#666); font-size:0.8rem; padding:8px 14px;';
typing.textContent = '🤖 Thinking...';
container.appendChild(typing);
container.scrollTop = container.scrollHeight;
try {
const res = await secureFetch('/api/v1/ai/intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// history: prior turns EXCLUDING the just-added user line
body: JSON.stringify({ message: raw.slice(0, 1000), history: chatTurns.slice(0, -1) }),
});
const data = await res.json();
typing.remove();
if (data && data.success !== false && data.intent) {
const resp = data.response || {};
const text = resp.message || data.llmReply || ('Action: ' + (data.action || data.intent));
addMessage('ai', text, {
...resp,
engine: data.engine,
llmError: data.llmError,
appId: data.appId || resp.appId,
appName: data.appName || resp.appName,
action: data.intent,
suggestions: resp.suggestions,
});
const badge = document.getElementById('ai-status');
if (badge && data.engine === 'llm') {
// remember live engine flip (config could have just been set)
engineInfo.engine = 'llm';
}
} else {
addMessage('ai', (data && data.error) || 'Sorry, something went wrong.');
}
} catch (e) {
typing.remove();
addMessage('ai', 'Connection error: ' + e.message);
}
}
async function deploy(appId) {
if (typeof window.openDeployConfigFor === 'function') {
const r = await window.openDeployConfigFor(appId);
if (r === true) { addMessage('ai', `Opened the deploy setup for ${appId} — pick a subdomain and confirm.`); return; }
if (r === 'selector') { addMessage('ai', `${appId} is a dashboard widget — open it from the app grid to toggle it.`); return; }
}
addMessage('ai', `Template "${appId}" not found in the catalog. Open the + menu to browse apps.`);
}
// ── ⚙ settings modal ──────────────────────────────────────────────────────
function openSettings() {
const old = document.getElementById('ai-llm-settings-modal');
if (old) old.remove();
const m = document.createElement('div');
m.id = 'ai-llm-settings-modal';
m.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:10000; display:flex; align-items:center; justify-content:center;';
m.innerHTML = `
<div style="background:var(--bg-card,#16162a); border:1px solid var(--border,#333); border-radius:12px; padding:20px; width:420px; max-width:92vw; display:flex; flex-direction:column; gap:10px;">
<div style="font-weight:700;">AI assistant — LLM engine</div>
<div class="ai-note" style="color:var(--text-muted,#888); font-size:0.75rem;">Any OpenAI-compatible endpoint. Stored in the encrypted credential store (never in browser-exposed config). Leave the key blank to keep the stored one. Without an LLM the assistant still works on the offline pattern engine.</div>
<label style="font-size:0.78rem;">Base URL <input id="ai-cfg-base" class="ai-in" placeholder="https://api.example.com/v1" style="width:100%;box-sizing:border-box;"></label>
<label style="font-size:0.78rem;">Model <input id="ai-cfg-model" class="ai-in" placeholder="gpt-4o-mini"></label>
<label style="font-size:0.78rem;">API key <input id="ai-cfg-key" class="ai-in" type="password" autocomplete="off" placeholder="stored key unchanged"></label>
<div id="ai-cfg-state" class="ai-note"></div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button id="ai-cfg-test" class="ai-act blue">Test</button>
<button id="ai-cfg-save" class="ai-act deploy">Save</button>
<button id="ai-cfg-clear" class="ai-act warn">Remove LLM</button>
<button id="ai-cfg-close" class="ai-act">Close</button>
</div>
</div>`;
document.body.appendChild(m);
const $ = (id) => document.getElementById(id);
const state = (t) => { $('ai-cfg-state').textContent = t; };
fetch('/api/v1/ai/settings').then((r) => r.json()).then((d) => {
if (d && d.success) {
$('ai-cfg-base').value = d.baseUrl || '';
$('ai-cfg-model').value = d.model || '';
state(d.configured ? `Active via ${d.source}. ${d.hasApiKey ? 'Key stored.' : 'No key.'}` : 'Not configured — pattern engine in use.');
if (d.source === 'env') $('ai-cfg-key').placeholder = 'env key in use — set to override';
}
}).catch(() => state('Could not load current settings.'));
$('ai-cfg-close').onclick = () => m.remove();
m.addEventListener('click', (e) => { if (e.target === m) m.remove(); });
$('ai-cfg-save').onclick = async () => {
state('Saving…');
try {
const r = await secureFetch('/api/v1/ai/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseUrl: $('ai-cfg-base').value, model: $('ai-cfg-model').value, apiKey: $('ai-cfg-key').value }),
});
const d = await r.json();
state(d.success ? 'Saved. The assistant now classifies with ' + (d.model || 'your model') + '.' : (d.error || 'Save failed.'));
if (d.success) { $('ai-cfg-key').value = ''; refreshEngineBadge(); }
} catch (e) { state('Save error: ' + e.message); }
};
$('ai-cfg-test').onclick = async () => {
state('Testing… (one classification round-trip)');
try {
const r = await secureFetch('/api/v1/ai/settings/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const d = await r.json();
if (d.success && d.ok) state(`✅ Works — sample: "${d.sample.intent}"${d.sample.reply ? ' — ' + d.sample.reply : ''}`);
else state('❌ ' + (d.reason || d.error || 'test failed'));
} catch (e) { state('Test error: ' + e.message); }
};
$('ai-cfg-clear').onclick = async () => {
state('Removing…');
try {
const r = await secureFetch('/api/v1/ai/settings', { method: 'DELETE' });
const d = await r.json();
state(d.success ? 'Removed — pattern engine active.' : (d.error || 'failed'));
refreshEngineBadge();
} catch (e) { state('Error: ' + e.message); }
};
}
document.addEventListener('click', (e) => {
if (e.target && e.target.id === 'ai-settings-btn') openSettings();
}, true);
window.aiChat = { send, deploy, toggle };
window.toggleAIChat = toggle;
})();