// 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 = `
AI assistant — LLM engine
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.
`; 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; })();