[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:
9 files changed
+1313
-293
No files matched your search
+262
-85
@@ -1,7 +1,53 @@
|
||||
// AI Chat Panel — DashCaddy AI Intent Router frontend
|
||||
(function() {
|
||||
let chatMessages = [];
|
||||
let chatHistory = [];
|
||||
// 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');
|
||||
@@ -9,110 +55,157 @@
|
||||
|
||||
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: 500px; 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);
|
||||
`;
|
||||
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);';
|
||||
|
||||
panel.innerHTML = `
|
||||
<div style="padding: 14px 18px; border-bottom: 1px solid var(--border,#333); display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.3rem;">🤖</span>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight: 700; font-size: 0.95rem;">AI Assistant</div>
|
||||
<div id="ai-status" style="font-size: 0.72rem; color: var(--text-muted,#666);">Pattern matching • No key needed</div>
|
||||
</div>
|
||||
<button onclick="document.getElementById('ai-chat-panel').style.display='none'" style="background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-muted,#888);">×</button>
|
||||
</div>
|
||||
<div id="ai-chat-messages" style="flex:1; overflow-y:auto; padding:14px; display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="background:rgba(99,102,241,0.08); border-radius:10px; padding:12px; font-size:0.85rem; color:var(--text-muted,#aaa);">
|
||||
👋 Hi! I can help you deploy apps, diagnose issues, and manage your server. Try:
|
||||
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Deploy Plex"')" style="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;">Deploy Plex</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"I want to stream movies"')" style="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;">Stream movies</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Block ads on my network"')" style="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;">Block ads</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Is everything OK?')" style="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;">System health</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px; border-top:1px solid var(--border,#333);">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<input id="ai-chat-input" type="text" placeholder="Ask me anything..."
|
||||
onkeypress="if(event.key==='Enter') window.aiChat.send()"
|
||||
style="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;">
|
||||
<button onclick="window.aiChat.send()" style="background:#6366f1; border:none; border-radius:8px; padding:0 16px; color:#fff; cursor:pointer; font-weight:600;">→</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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) { createChatPanel(); 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');
|
||||
const isUser = role === 'user';
|
||||
div.style.cssText = isUser
|
||||
? '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;'
|
||||
: '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;';
|
||||
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);
|
||||
|
||||
// Extra content (recommendations, action buttons)
|
||||
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.style.cssText = 'font-size:0.7rem; color:var(--text-muted,#666); padding:6px 8px; line-height:1.4; margin-top:4px;';
|
||||
disc.textContent = '⚠️ ' + extra.disclaimer;
|
||||
disc.className = 'ai-note';
|
||||
disc.textContent = '⚠ ' + esc(extra.disclaimer);
|
||||
extraDiv.appendChild(disc);
|
||||
}
|
||||
if (extra.recommendations) {
|
||||
extra.recommendations.forEach(rec => {
|
||||
if (Array.isArray(extra.recommendations)) {
|
||||
extra.recommendations.forEach((rec) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = `📦 Deploy ${rec.app}`;
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
|
||||
btn.onclick = () => window.aiChat.send('Deploy ' + rec.app);
|
||||
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.textContent = `🚀 Deploy ${extra.appId}`;
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
|
||||
btn.onclick = () => window.aiChat.deploy(extra.appId);
|
||||
btn.className = 'ai-act deploy';
|
||||
btn.textContent = `🚀 Deploy ${esc(extra.appName || extra.appId)}`;
|
||||
btn.addEventListener('click', () => deploy(extra.appId));
|
||||
extraDiv.appendChild(btn);
|
||||
}
|
||||
if (extra.suggestions) {
|
||||
const list = document.createElement('div');
|
||||
list.style.cssText = 'font-size:0.78rem; color:var(--text-muted,#888); padding:4px 0;';
|
||||
list.innerHTML = extra.suggestions.map(s => '• ' + s).join('<br>');
|
||||
extraDiv.appendChild(list);
|
||||
}
|
||||
if (extra.action === 'diagnose') {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ai-act warn';
|
||||
btn.textContent = '🔍 Run Diagnostics';
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#fbbf24;';
|
||||
btn.onclick = () => window.location.href = '#health';
|
||||
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.style.cssText = 'display:block; margin:4px 0; background:rgba(99,102,241,0.12); border:1px solid rgba(99,102,241,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#a5b4fc;';
|
||||
btn.onclick = () => { if (window.createBackup) window.createBackup(); };
|
||||
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;
|
||||
@@ -120,16 +213,14 @@
|
||||
|
||||
async function send(prefilled) {
|
||||
const input = document.getElementById('ai-chat-input');
|
||||
const message = prefilled || input.value.trim();
|
||||
if (!message) return;
|
||||
input.value = '';
|
||||
const raw = prefilled || (input && input.value.trim()) || '';
|
||||
if (!raw) return;
|
||||
if (input) input.value = '';
|
||||
|
||||
addMessage('user', message);
|
||||
addMessage('user', raw);
|
||||
|
||||
// Typing indicator
|
||||
const container = document.getElementById('ai-chat-messages');
|
||||
const typing = document.createElement('div');
|
||||
typing.id = 'ai-typing';
|
||||
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);
|
||||
@@ -139,17 +230,31 @@
|
||||
const res = await secureFetch('/api/v1/ai/intent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, context: {} })
|
||||
// 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.success !== false && data.data) {
|
||||
const r = data.data;
|
||||
const response = r.response || {};
|
||||
addMessage('ai', response.message || r.response?.message || 'Action: ' + (r.action || r.intent), response);
|
||||
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.error || 'Sorry, something went wrong.');
|
||||
addMessage('ai', (data && data.error) || 'Sorry, something went wrong.');
|
||||
}
|
||||
} catch (e) {
|
||||
typing.remove();
|
||||
@@ -158,16 +263,88 @@
|
||||
}
|
||||
|
||||
async function deploy(appId) {
|
||||
addMessage('user', 'Deploy ' + appId);
|
||||
addMessage('ai', `Launching ${appId} from the catalog...`);
|
||||
// Trigger the app selector with this app
|
||||
if (window.openAppSelector) {
|
||||
window.openAppSelector(appId);
|
||||
} else {
|
||||
addMessage('ai', `Search for ${appId} in the App Catalog to deploy it.`);
|
||||
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;
|
||||
})();
|
||||
@@ -917,6 +917,28 @@
|
||||
modal.classList.add('show');
|
||||
});
|
||||
|
||||
// DC-163: programmatic deploy-config opener for the AI chat panel (and
|
||||
// any other in-page consumer). Resolves the template id against the
|
||||
// cached API templates (fetching them if needed), then opens the same
|
||||
// deploy modal a manual click would. Returns false for unknown ids.
|
||||
window.openDeployConfigFor = async function (templateId) {
|
||||
if (!apiTemplates) {
|
||||
const okFetch = await fetchAppTemplates();
|
||||
if (!okFetch) return false;
|
||||
}
|
||||
const key = String(templateId || '').toLowerCase();
|
||||
const entry = Object.entries(apiTemplates).find(([id]) => id.toLowerCase() === key);
|
||||
if (!entry) return false;
|
||||
const app = { id: entry[0], ...entry[1] };
|
||||
if (app.isDashboardWidget) {
|
||||
await buildAppSelector();
|
||||
modal.classList.add('show');
|
||||
return 'selector'; // widget — point at the selector grid instead
|
||||
}
|
||||
await showDeployConfig(app);
|
||||
return true;
|
||||
};
|
||||
|
||||
wireModal(modal, document.getElementById('app-selector-cancel'));
|
||||
|
||||
// Deploy modal event listeners
|
||||
|
||||
Reference in new issue
Block a user