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
356 lines
18 KiB
JavaScript
356 lines
18 KiB
JavaScript
// 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;
|
||
})();
|