Files
dashcaddy/status/js/ai-chat.js
T
Hermes 94ee76e9de [grade=A urn:ump:josd2g7kqv476vux6euigmygdkijya2s4tsbyivtobw5ftzuf6ba] fix(DC-170): revive 19 CSP-blocked inline handlers across the dashboard — audit P0-3
The meta CSP (no unsafe-inline/unsafe-hashes) blocks ALL inline event
handlers; 19 controls were silently dead in every modern browser.

Revived:
- Disk Safety: toolbar button, injected modal Close/Save/Clean Up, and
  the 5 settings sliders (oninput -> input listeners; value labels
  never updated)
- AI chat FAB click; hover scale moved to a #ai-chat-fab:hover rule
- TOTP/login logo broken-image fallbacks: capture-phase error
  delegation + WeakSet + complete/naturalWidth race guard in
  totp-auth.js; updateTotpLogo never resurrects a broken logo
  (6 onerror attrs stripped from index.html + auth-gate.js templates)
- favicon-preview data-URI fallback (same race guard)
- app-selector folder-chip remove buttons (data-remove-folder)
- premium upsells in backup-restore + workflows (data-upgrade-msg) —
  their old onclick also called scrollToSection('license'), a function
  that has never existed anywhere in the codebase; now opens the real
  license UI via window.openLicenseModal (license.js)
- health-check Edit/Delete dispatch the same CustomEvents via
  data-health-action/data-service-id
- smart-arr Test button via data-smart-test

Verified: zero inline handler attributes remain across index.html +
hand-written js (grep); node --check clean x12; status suite 68/68;
dist rebuilt (SW dashcaddy-shell-36d9aa3364, CSP hash unchanged).
r1 grade C (missed sliders + logo onerror) -> r2 grade A:
urn:ump:mptri3qhywc2e47f2ba4qjafhwm3olzvrvt5vphxoyhea3vgt32a /
urn:ump:josd2g7kqv476vux6euigmygdkijya2s4tsbyivtobw5ftzuf6ba
2026-10-28 00:48:32 -07:00

356 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);
// Audit P0-3: the FAB's inline onclick/onmouseover/onmouseout were dead
// under the meta CSP. Click wired here; hover moved to a CSS :hover rule.
const fab = document.getElementById('ai-chat-fab');
if (fab) fab.addEventListener('click', () => toggle());
window.aiChat = { send, deploy, toggle };
window.toggleAIChat = toggle;
})();