AI_Chatbot / admin.html
Hamza-Naimat's picture
production: harden catalog tenants analytics and widget install
59bd3b5
Raw
History Blame Contribute Delete
167 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Admin Dashboard β€” Digital FTE</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--primary: #6366f1;
--primary-dark: #4f46e5;
--secondary: #64748b;
--bg: #f8fafc;
--card-bg: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--radius: 16px;
--shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background-color: var(--bg);
color: var(--text-main);
padding: 40px 20px;
}
.container { max-width: 1000px; margin: 0 auto; }
header { margin-bottom: 32px; display: flex; justify-content: space-between; align-items: flex-end; }
h1 { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; }
.subtitle { color: var(--text-muted); font-size: 14px; }
/* Security Overlay */
.auth-section { margin-bottom: 32px; padding: 24px; background: white; border-radius: var(--radius); border: 2.2px solid var(--primary); box-shadow: var(--shadow); }
.locked .card { display: none !important; }
.locked .card-body { pointer-events: none; opacity: 0.4; filter: blur(3px); }
.locked .card-header { cursor: not-allowed; }
.card { background: var(--card-bg); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow); margin-bottom: 24px; overflow: hidden; }
.card-header { padding: 20px 24px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; background: #fff; border-bottom: 1px solid transparent; }
.card.open .card-header { border-bottom-color: var(--border); }
.card-header h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.card-arrow { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #f1f5f9; border-radius: 50%; font-size: 9px; transition: transform 0.3s; }
.card-body { padding: 24px; display: none; }
.card.open .card-body { display: block; }
.card.open .card-arrow { transform: rotate(180deg); }
label { display: block; font-size: 11px; font-weight: 800; color: var(--text-muted); margin: 16px 0 6px; text-transform: uppercase; letter-spacing: 0.03em; }
input, select, textarea {
width: 100%; padding: 12px 16px; background: #fff; border: 1.5px solid var(--border); border-radius: 12px; font-size: 14px; outline: none; transition: all 0.2s; font-family: inherit;
}
input:focus, textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
button {
width: 100%; padding: 12px; background: var(--primary); color: white; border: none; border-radius: 12px; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s; margin-top: 14px;
}
button:hover { background: var(--primary-dark); transform: translateY(-1px); }
button.secondary { background: #f1f5f9; color: var(--text-main); border: 1px solid var(--border); }
button.danger { background: #fee2e2; color: #dc2626; border: 1px solid #fecaca; }
button.outline { background: transparent; border: 1.5px solid var(--primary); color: var(--primary); }
/* Custom Color Box UI */
.color-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 12px; }
.color-input-box {
border: 1.5px solid var(--border); border-radius: 16px; padding: 12px; text-align: center; background: #f8fafc;
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.color-visual { width: 100%; height: 48px; border-radius: 10px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.1); cursor: pointer; }
.status-msg { margin-top: 12px; padding: 12px 16px; border-radius: 10px; font-size: 12px; font-weight: 600; display: none; }
.status-success { background: #dcfce7; color: #166534; display: block; border: 1px solid #bbf7d0; }
.status-error { background: #fee2e2; color: #991b1b; display: block; border: 1px solid #fecaca; }
.status-loading { background: #e0f2fe; color: #0c4a6e; display: block; }
.badge { padding: 4px 10px; border-radius: 8px; font-size: 10px; font-weight: 800; text-transform: uppercase; }
.badge-current { background: var(--primary); color: white; }
.badge-pass { background: #dcfce7; color: #166534; }
.badge-fail { background: #fee2e2; color: #991b1b; }
.badge-na { background: #f1f5f9; color: #475569; }
#brand-preview { border: 1px solid var(--border); border-radius: 20px; overflow: hidden; max-width: 320px; margin-top: 16px; box-shadow: var(--shadow); }
#prev-header { padding: 16px; display: flex; align-items: center; gap: 12px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; margin-top: 12px; }
th { text-align: left; padding: 12px; font-size: 11px; font-weight: 800; color: var(--text-muted); border-bottom: 2px solid var(--border); }
td { padding: 14px 12px; font-size: 13px; border-bottom: 1px solid var(--border); }
.db-item { border: 1.5px solid var(--border); border-radius: 16px; padding: 16px; background: #fff; margin-bottom: 12px; transition: all 0.2s; }
.db-item.active { border-color: var(--primary); background: rgba(99, 102, 241, 0.03); }
.db-row { display: flex; justify-content: space-between; align-items: center; }
.db-actions { display: flex; gap: 8px; }
.db-details { display: none; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); font-size: 12px; color: var(--text-muted); }
.db-item.expanded .db-details { display: block; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Custom Confirm Modal ── */
#confirm-overlay {
display: none; position: fixed; inset: 0; background: rgba(15,23,42,0.55);
z-index: 9999; align-items: center; justify-content: center;
}
#confirm-overlay.active { display: flex; }
#confirm-box {
background: #fff; border-radius: 20px; padding: 28px 28px 20px;
max-width: 400px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,0.18);
animation: popIn 0.18s ease-out;
}
@keyframes popIn { from { transform: scale(0.88); opacity: 0; } to { transform: scale(1); opacity: 1; } }
#confirm-box h3 { margin: 0 0 8px; font-size: 16px; color: #1e293b; }
#confirm-box p { margin: 0 0 20px; font-size: 13px; color: #64748b; line-height: 1.5; }
#confirm-box .btn-row { display: flex; gap: 10px; justify-content: flex-end; }
#confirm-box .btn-cancel { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; border-radius: 10px; padding: 9px 18px; cursor: pointer; font-size: 13px; font-weight: 600; }
#confirm-box .btn-ok { background: #ef4444; color: #fff; border: none; border-radius: 10px; padding: 9px 18px; cursor: pointer; font-size: 13px; font-weight: 600; }
#confirm-box .btn-ok.safe { background: #6366f1; }
/* Ò”€Ò”€ Eval Detail Overlay Ò”€Ò”€ */
#eval-overlay {
display: none; position: fixed; inset: 0; background: rgba(15,23,42,0.55);
z-index: 10000; align-items: center; justify-content: center; padding: 20px;
}
#eval-overlay.active { display: flex; }
#eval-box {
background: #fff; border-radius: 18px; padding: 22px;
max-width: 980px; width: 100%; max-height: 86vh; overflow: auto;
box-shadow: 0 24px 70px rgba(0,0,0,0.20);
}
.eval-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: 10px; font-size: 11px; font-weight: 800;
border: 1px solid var(--border); background: #f8fafc;
}
.eval-row-meta { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
.eval-answer-box {
white-space: pre-wrap; background: #0f172a; color: #e2e8f0;
border-radius: 12px; padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; line-height: 1.5;
}
.eval-section { margin-top: 14px; }
.eval-section-title {
font-size: 11px; font-weight: 800; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px;
}
.eval-section-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
.eval-panel {
border:1px solid var(--border); border-radius:12px; padding:12px; background:#fff;
}
.eval-panel h4 {
font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted); margin-bottom:8px;
}
.eval-plain-box {
white-space: pre-wrap; font-size: 13px; line-height: 1.55; color: var(--text-main);
background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:12px;
}
.eval-answer-box.answer-expected {
background:#f8fafc; color:var(--text-main); border:1px solid var(--border); font-family:inherit;
}
.eval-facts { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.eval-facts .panel { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: #fff; }
.eval-facts h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 8px; }
.eval-fact { font-size: 12px; padding: 8px 10px; border-radius: 10px; background: #f8fafc; border: 1px solid var(--border); margin-bottom: 8px; }
.eval-src { font-size: 12px; color: var(--text-muted); margin-top: 8px; }
.eval-mini-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap:8px; margin-top:12px; }
.eval-mini-card { border:1px solid var(--border); border-radius:10px; padding:10px 12px; background:#fff; }
.eval-mini-card .k { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.04em; }
.eval-mini-card .v { font-size:14px; font-weight:800; margin-top:4px; line-height:1.35; white-space:pre-wrap; }
.eval-summary-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-top:12px; }
.eval-summary-panel { border:1px solid var(--border); border-radius:10px; padding:12px; background:#fff; }
.eval-summary-panel h4 { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-muted); margin-bottom:8px; }
@media (max-width: 860px) {
.eval-section-grid, .eval-facts, .eval-summary-grid { grid-template-columns: 1fr; }
}
/* Client mode β€” hide owner-only sections */
body.client-mode #card-tests,
body.client-mode #card-evals,
body.client-mode #card-crawl,
body.client-mode #card-db,
body.client-mode #owner-only-api,
body.client-mode #owner-only-create,
body.client-mode #owner-only-brand,
body.client-mode #owner-only-reindex,
body.client-mode #owner-only-cache,
body.client-mode #card-embed,
body.client-mode #global-status { display: none !important; }
#client-preview-section { display: none; }
body.client-mode #client-preview-section { display: block !important; }
</style>
<!-- Custom Confirm Modal -->
<div id="confirm-overlay">
<div id="confirm-box">
<h3 id="confirm-title">Are you sure?</h3>
<p id="confirm-msg"></p>
<div class="btn-row">
<button class="btn-cancel" onclick="confirmResolve(false)">Cancel</button>
<button class="btn-ok" id="confirm-ok-btn" onclick="confirmResolve(true)">Confirm</button>
</div>
</div>
</div>
<!-- Eval Detail Overlay -->
<div id="eval-overlay" onclick="hideEvalOverlay()">
<div id="eval-box" onclick="event.stopPropagation()">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;">
<div style="flex:1;">
<div style="font-size:11px;font-weight:800;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;">Eval Details</div>
<div id="eval-detail-q" style="font-size:16px;font-weight:800;margin-top:6px;"></div>
<div id="eval-detail-meta" class="eval-row-meta"></div>
</div>
<button class="secondary" style="width:auto;margin:0;padding:8px 12px;" onclick="hideEvalOverlay()">Close</button>
</div>
<div id="eval-detail-src" class="eval-src"></div>
<div class="eval-section">
<div class="eval-section-title">Expected vs Actual</div>
<div class="eval-section-grid">
<div class="eval-panel">
<h4>Expected Answer / Reference</h4>
<div id="eval-detail-expected" class="eval-answer-box answer-expected"></div>
</div>
<div class="eval-panel">
<h4>Bot Answer</h4>
<div id="eval-detail-answer" class="eval-answer-box"></div>
</div>
</div>
</div>
<div class="eval-section">
<div class="eval-section-title">Diagnosis</div>
<div class="eval-section-grid">
<div class="eval-panel">
<h4>What Broke</h4>
<div id="eval-detail-diagnosis" class="eval-plain-box"></div>
</div>
<div class="eval-panel">
<h4>Likely Cause</h4>
<div id="eval-detail-cause" class="eval-plain-box"></div>
</div>
</div>
</div>
<div class="eval-section">
<div class="eval-section-title">Metrics</div>
<div id="eval-detail-metrics" class="eval-mini-grid"></div>
</div>
<div class="eval-facts">
<div class="panel">
<h4>Key Facts Present</h4>
<div id="eval-detail-present"></div>
</div>
<div class="panel">
<h4>Key Facts Missing</h4>
<div id="eval-detail-missing"></div>
</div>
</div>
<div class="eval-section">
<div class="eval-section-title">Retrieved Sources</div>
<div id="eval-detail-sources"></div>
</div>
</div>
</div>
</head>
<body class="locked">
<div class="container">
<header>
<div><h1>Mega Admin</h1><p class="subtitle">Secure Fleet Orchestration</p></div>
<div id="global-status" style="font-size:12px; font-weight:800; color:var(--primary);"></div>
</header>
<!-- Master Unlock -->
<div class="auth-section">
<div style="text-align:center; margin-bottom:20px;">
<div style="font-size:28px; margin-bottom:6px;">πŸ”</div>
<div style="font-size:16px; font-weight:700; color:var(--text-main);">Admin Access</div>
<div style="font-size:12px; color:var(--text-muted); margin-top:2px;">Enter your database name and password to continue</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px;">
<div>
<label style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-muted);">Database Name</label>
<div id="admin-db-container" style="margin-top:5px;"></div>
</div>
<div>
<label style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-muted);">Password</label>
<div style="position:relative; margin-top:5px;">
<div id="master-pass-container"></div>
<button type="button" onclick="togglePassVis()" id="eye-btn" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;padding:0;margin:0;width:auto;color:#94a3b8;">
<svg id="eye-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
</div>
<button onclick="unlockSystem()" style="width:100%; margin:0;">Verify &amp; Unlock</button>
<div id="auth-status" class="status-msg"></div>
</div>
<!-- 1. Visual Analytics -->
<div class="card" id="card-stats">
<div class="card-header" onclick="toggle('stats')"><h2>πŸ“Š Visual Analytics Dashboard</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<div class="grid-2" style="margin-bottom:24px;">
<div class="stat-card" style="background:var(--primary); color:white; padding:20px; border-radius:16px;">
<div style="font-size:10px; font-weight:800; opacity:0.8;">TOTAL INTERACTIONS</div>
<div style="font-size:32px; font-weight:800;" id="stat-total">0</div>
</div>
<div class="stat-card" style="background:#10b981; color:white; padding:20px; border-radius:16px;">
<div style="font-size:10px; font-weight:800; opacity:0.8;">AVG CSAT</div>
<div style="font-size:32px; font-weight:800;" id="stat-csat">β€”</div>
</div>
</div>
<div id="stat-extra" style="font-size:12px;color:var(--text-muted);margin-top:-12px;margin-bottom:20px;"></div>
<div class="grid-2">
<div>
<h3 style="font-size:12px; font-weight:800; color:var(--text-muted); margin-bottom:12px; text-transform:uppercase;">πŸ”₯ Most Asked Questions (Top 5)</h3>
<table style="font-size:12px;">
<tbody id="stat-most-asked"></tbody>
</table>
</div>
<div>
<h3 style="font-size:12px; font-weight:800; color:var(--text-muted); margin-bottom:12px; text-transform:uppercase;">πŸ•’ Most Recent History</h3>
<table style="font-size:12px;">
<tbody id="stat-most-recent"></tbody>
</table>
</div>
</div>
<button class="secondary" onclick="loadAnalytics()" style="margin-top:24px;">πŸ”„ Refresh Analytics</button>
<div id="stats-status" class="status-msg"></div>
<hr style="margin:24px 0;border:none;border-top:1px solid #e2e8f0;">
<h3 style="font-size:12px;font-weight:800;color:var(--text-muted);margin:0 0 12px;text-transform:uppercase;">πŸ“ˆ Activity Charts</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
<div><canvas id="chart-queries" height="160"></canvas></div>
<div><canvas id="chart-csat" height="160"></canvas></div>
</div>
<div style="max-width:600px;margin-bottom:24px;">
<canvas id="chart-gaps" height="120"></canvas>
</div>
<hr style="margin:24px 0;border:none;border-top:1px solid #e2e8f0;">
<h3 style="font-size:12px;font-weight:800;color:var(--text-muted);margin-bottom:12px;text-transform:uppercase;">❓ Knowledge Gaps (Unanswered Questions)</h3>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:10px;">Questions your bot couldn't answer β€” use these to improve your KB.</p>
<button class="secondary" onclick="loadKnowledgeGaps()" style="margin-bottom:12px;">πŸ”„ Load Knowledge Gaps</button>
<div id="knowledge-gaps-list" style="font-size:13px;"></div>
</div>
</div>
<!-- 2. Operations & Contact -->
<div class="card" id="card-ops">
<div class="card-header" onclick="toggle('ops')"><h2>πŸ•’ Operations & Contact Settings</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<div class="grid-2">
<div><label>Primary Contact Email (receives leads)</label><input type="text" id="ops-email" placeholder="support@company.com"/></div>
<div><label>WhatsApp Number</label><input type="text" id="ops-whatsapp" placeholder="+1234567890"/></div>
</div>
<h3 style="font-size:12px;font-weight:800;color:var(--text-muted);margin:24px 0 12px;text-transform:uppercase;">πŸ“§ Email Notifications (Gmail)</h3>
<div class="grid-2">
<div><label>Gmail App Password</label><input type="password" id="ops-smtp-password" placeholder="xxxx xxxx xxxx xxxx" autocomplete="off"/></div>
</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px;">The contact email above will self-send lead notifications. Β· <a href="https://myaccount.google.com/apppasswords" target="_blank" style="color:var(--primary)">Get Gmail App Password β†—</a></p>
<div class="grid-2"><button class="secondary" onclick="loadOps()">πŸ”„ Load Current</button><button onclick="saveOps()">πŸš€ Save Operational Settings</button></div>
<div id="ops-status" class="status-msg"></div>
</div>
</div>
<!-- 3. Branding -->
<div class="card" id="card-brand">
<div class="card-header" onclick="toggle('brand')"><h2>🎨 Full UI & Branding Suite</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<div class="grid-2">
<div><label>Bot Name</label><input type="text" id="b-bot-name"/></div>
<div><label>Business Name</label><input type="text" id="b-biz-name"/></div>
</div>
<div class="grid-2">
<div><label>Header Title</label><input type="text" id="b-header-title"/></div>
<div><label>Header Subtitle</label><input type="text" id="b-header-subtitle"/></div>
</div>
<div class="grid-2">
<div><label>Logo Emoji</label><input type="text" id="b-logo-emoji" maxlength="4"/></div>
<div><label>Logo URL</label><input type="text" id="b-logo-url"/></div>
</div>
<label>Welcome Message</label><input type="text" id="b-welcome"/>
<label>Input Placeholder</label><input type="text" id="b-placeholder"/>
<div id="owner-only-brand">
<label>Secondary / Domain Prompt (Expert Runbook)</label>
<textarea id="b-secondary-prompt" placeholder="Additional instructions for the AI for this specific database..." style="height:100px; margin-bottom:12px;"></textarea>
</div>
<div class="grid-2">
<div><label>Page Title</label><input type="text" id="b-page-title"/></div>
<div><label>Font Style</label><select id="b-font-style">
<option value="Modern Sans">Modern Sans</option>
<option value="Elegant Serif">Elegant Serif</option>
<option value="Tech Mono">Tech Mono</option>
<option value="Friendly Rounded">Friendly Rounded</option>
</select></div>
</div>
<div class="color-grid">
<div class="color-input-box"><label>Primary</label><input type="color" class="color-visual" id="b-primary-hex"/></div>
<div class="color-input-box"><label>Secondary</label><input type="color" class="color-visual" id="b-secondary-hex"/></div>
<div class="color-input-box"><label>Bot Bubble</label><input type="color" class="color-visual" id="b-bot-bubble-hex"/></div>
<div class="color-input-box"><label>User Bubble</label><input type="color" class="color-visual" id="b-user-bubble-hex"/></div>
</div>
<label>Visual Live Preview</label>
<div id="brand-preview" style="border: 1px solid var(--border); border-radius: 24px; overflow: hidden; max-width: 320px; margin-top: 16px; box-shadow: 0 20px 50px rgba(0,0,0,0.1); background: white;">
<!-- Header -->
<div id="prev-header" style="background:var(--primary); color:white; display:flex; align-items:center; gap:12px; padding:20px; border-bottom: 1px solid rgba(255,255,255,0.1);">
<div style="position: relative;">
<span id="prev-logo" style="font-size:28px; background: rgba(255,255,255,0.2); width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 12px;">πŸ€–</span>
<div style="position: absolute; bottom: -2px; right: -2px; width: 12px; height: 12px; background: #10b981; border: 2px solid white; border-radius: 50%;"></div>
</div>
<div>
<div id="prev-title" style="font-weight:800; font-size:15px; letter-spacing: -0.01em;">Support</div>
<div id="prev-subtitle" style="font-size:11px; opacity:0.9; display: flex; align-items: center; gap: 4px;">
Active Now
</div>
</div>
</div>
<!-- Chat Area -->
<div style="padding:20px; background:#f8fafc; height:220px; display:flex; flex-direction:column; gap:14px; overflow:hidden;">
<div id="prev-bot-bubble" style="background:#fff; color: var(--text-main); padding:12px 16px; border-radius:4px 16px 16px 16px; font-size:12px; align-self:flex-start; max-width:85%; box-shadow: 0 2px 5px rgba(0,0,0,0.02); line-height: 1.5;">Hi! I am your Digital FTE. How can I help you today?</div>
<div id="prev-user-bubble" style="background:linear-gradient(135deg,var(--primary),#4f46e5); color:white; padding:12px 16px; border-radius:16px 16px 4px 16px; font-size:12px; align-self:flex-end; max-width:85%; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); line-height: 1.5;">I want to automate my business.</div>
</div>
<!-- Input Area -->
<div style="padding:16px; background:white; border-top:1px solid var(--border); display:flex; gap:12px; align-items: center;">
<div id="prev-input" style="flex:1; height:40px; background:#f1f5f9; border-radius:20px; border:1px solid var(--border); padding:0 16px; font-size:12px; color:var(--text-muted); display: flex; align-items: center;">Type a message...</div>
<div id="prev-send-btn" style="width:40px; height:40px; background:linear-gradient(135deg,var(--primary),#4f46e5); border-radius:50%; display:flex; align-items:center; justify-content:center; color:white; font-size:16px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); cursor: pointer;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
</div>
</div>
</div>
<div class="grid-2"><button class="secondary" onclick="loadBranding()">πŸ”„ Load Current</button><button onclick="saveBranding()">πŸš€ Push Branding</button></div>
<div id="brand-status" class="status-msg"></div>
</div>
</div>
<!-- 3. Database Hub -->
<div class="card" id="card-db">
<div class="card-header" onclick="toggle('db')"><h2>πŸ—„οΈ Database Hub & Selector</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<div id="owner-only-create">
<label>Create New Knowledge Repository</label>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<input type="text" id="new-db-name" placeholder="db_name" style="flex:1;min-width:120px;" autocomplete="off" readonly onfocus="this.removeAttribute('readonly')"/>
<input type="password" id="new-db-pass" placeholder="Client password" style="flex:1;min-width:140px;" autocomplete="new-password" readonly onfocus="this.removeAttribute('readonly')"/>
<button onclick="createDb()" style="width:auto; margin:0; background:#10b981;">+ Create</button>
</div>
<div style="display:flex;gap:12px;align-items:center;margin-top:24px;">
<label style="margin:0;">Manage Repositories</label>
<button onclick="syncFromGithub()" style="width:auto;margin:0;padding:6px 16px;font-size:12px;background:#6366f1;">☁️ Sync from GitHub</button>
</div>
</div>
<div id="db-list"></div>
<div id="db-status" class="status-msg"></div>
</div>
</div>
<!-- 4. Website Crawler -->
<div class="card" id="card-crawl">
<div class="card-header" onclick="toggle('crawl')"><h2>🌐 Website Crawler</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<label>Target Site URL</label><input type="text" id="crawl-url" placeholder="https://..."/>
<label>Target DB</label><input type="text" id="crawl-db-name" placeholder="agentfactory"/>
<label>Embedding Model</label>
<select id="crawl-embedding-model" style="width:100%; padding:10px 14px; border-radius:10px; border:1.5px solid var(--border); font-size:13px; margin-bottom:8px;">
<option value="bge">BGE-768 (Recommended β€” better quality)</option>
<option value="minilm">MiniLM-384 (Legacy β€” only for old DBs)</option>
</select>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
<input type="checkbox" id="crawl-clear-first" style="width:18px;height:18px;cursor:pointer;">
<label for="crawl-clear-first" style="margin:0;cursor:pointer;">πŸ—‘οΈ Clear existing data before crawling (fresh re-crawl)</label>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
<label style="margin:0">Max Pages</label>
<label style="margin:0;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:5px;">
<input type="checkbox" id="crawl-no-limit" onchange="document.getElementById('crawl-max-pages').disabled=this.checked;document.getElementById('crawl-max-pages').style.opacity=this.checked?'0.4':'1'"/>
No limit
</label>
</div>
<input type="number" id="crawl-max-pages" value="2000" min="100" max="100000" style="width:100%;padding:10px 14px;border-radius:10px;border:1.5px solid var(--border);font-size:13px;margin-bottom:8px;"/>
<div style="display:flex;gap:8px;margin-top:4px;">
<button onclick="smartCrawlInspect()" style="background:#7c3aed;flex:1;">πŸ€– Smart Crawl</button>
<button onclick="fullCrawl()" style="background:#16a34a;flex:1;">⚑ Full Crawl</button>
</div>
<p style="font-size:11px;color:#94a3b8;margin:6px 0 0;">Smart = inspects &amp; classifies URLs first. Full = crawls everything up to max pages.</p>
<div id="smart-inspect-panel" style="display:none;margin-top:16px;border-top:1px solid var(--border);padding-top:16px;">
<h4 style="font-size:13px;font-weight:800;margin-bottom:6px;color:#7c3aed;">πŸ€– URL Classification Results</h4>
<p style="font-size:11px;color:#64748b;margin-bottom:10px;">LLM-rated URL groups. Check the ones you want to crawl, then start.</p>
<div id="smart-groups-table"></div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button onclick="startSmartCrawl()" style="background:#7c3aed;flex:1;">πŸš€ Crawl Selected Groups</button>
<button onclick="document.getElementById('smart-inspect-panel').style.display='none'" style="background:#f1f5f9;color:#475569;border:1px solid var(--border);flex:1;">βœ• Cancel</button>
</div>
</div>
<div id="crawl-status" class="status-msg"></div>
</div>
</div>
<!-- 5. Manual Ingestion -->
<div class="card" id="card-ingest">
<div class="card-header" onclick="toggle('ingest')"><h2>🧠 Knowledge Ingestion Suite</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<div style="display:flex; justify-content:space-between; align-items:center; gap:16px; background:#f1f5f9; padding:12px 16px; border-radius:12px; margin-bottom:20px; border:1px solid var(--border);">
<div>
<span style="font-size:10px; font-weight:800; color:var(--text-muted); text-transform:uppercase;">Target DB</span>
<div style="font-weight:800; color:var(--primary); font-size:13px;" id="active-ingest-db">Loading...</div>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<label style="margin:0; font-size:11px; font-weight:700; color:var(--text-muted); white-space:nowrap;">Ingest into:</label>
<select id="ingest-target-db" style="padding:6px 10px; border-radius:8px; border:1.5px solid var(--border); font-size:12px; font-weight:700; color:var(--text-main); background:#fff; cursor:pointer; min-width:130px;">
<option value="">β€” Active DB β€”</option>
</select>
</div>
</div>
<div id="owner-only-reindex" style="background:#fef9c3; border:1.5px solid #fde68a; border-radius:12px; padding:14px; margin-bottom:8px; display:flex; justify-content:space-between; align-items:center; gap:16px;">
<div style="font-size:12px; color:#92400e; line-height:1.5;"><strong>Rebuild with BGE (768-dim)</strong><br>Converts selected DB from legacy 384-dim to fast BGE. Run once β€” no data is lost.</div>
<button onclick="reindexDb()" style="width:auto; margin:0; padding:10px 20px; background:#d97706; white-space:nowrap;">⚑ Rebuild Index</button>
</div>
<label>Smart Ingest β€” paste text, JSON, CSV, YAML, XML, Markdown, or a URL</label>
<div style="position:relative;">
<textarea id="content" placeholder="Paste anything β€” text, JSON, CSV, YAML, XML, Markdown, or a URL like https://api.jikan.moe/v4/top/anime?limit=25" style="height:160px;padding-bottom:32px;" oninput="detectPasteFormat()"></textarea>
<div style="position:absolute;bottom:10px;left:10px;display:flex;align-items:center;gap:8px;">
<span id="fmt-badge" style="font-size:10px;font-weight:800;padding:2px 8px;border-radius:20px;background:#e2e8f0;color:#475569;display:none;"></span>
<span id="fmt-hint" style="font-size:10px;color:#94a3b8;"></span>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button onclick="updateText()" style="flex:1;">πŸ“₯ Ingest</button>
<select id="fmt-override" onchange="detectPasteFormat()" style="padding:6px 10px;border-radius:8px;border:1.5px solid var(--border);font-size:12px;font-weight:700;color:var(--text-main);background:#fff;min-width:110px;">
<option value="">Auto-detect</option>
<option value="txt">Plain Text</option>
<option value="json">JSON</option>
<option value="csv">CSV</option>
<option value="yaml">YAML</option>
<option value="xml">XML</option>
<option value="md">Markdown</option>
<option value="html">HTML</option>
</select>
</div>
<div id="ingest-status" class="status-msg" style="margin-top:10px;"></div>
<div style="margin-top:24px; border-top:1px solid var(--border); padding-top:24px;">
<label>File Upload β€” PDF, TXT, JSON, CSV, YAML, XML, DOCX, XLSX, MD</label>
<input type="file" id="file-input" accept=".txt,.pdf,.json,.csv,.yaml,.yml,.xml,.md,.markdown,.html,.htm,.docx,.xlsx" multiple/>
<button onclick="uploadFile()" class="secondary" style="margin-top:8px;">πŸ“€ Upload Files</button>
</div>
<div id="owner-only-api" style="margin-top:24px;border-top:1px solid var(--border);padding-top:24px;">
<div style="font-weight:700;font-size:13px;margin-bottom:4px;">πŸ”Œ Live API Sources</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">APIs polled automatically on schedule. If a user asks something not in the DB, the bot fetches live from these sources.</div>
<div id="api-sources-list" style="margin-bottom:12px;"></div>
<div style="background:#f8fafc;border-radius:10px;padding:14px;border:1px solid var(--border);">
<div style="font-weight:700;font-size:12px;margin-bottom:8px;">βž• Add API Source</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
<input type="text" id="api-src-name" placeholder="Name (e.g. Jikan Anime)"/>
<input type="url" id="api-src-url" placeholder="https://api.jikan.moe/v4/top/anime?limit=25"/>
<input type="text" id="api-src-key" placeholder="API Key (optional)"/>
<input type="text" id="api-src-path" placeholder="JSON path (e.g. data) β€” leave empty to auto-detect"/>
<input type="text" id="api-src-keywords" placeholder="Keywords (comma-sep): top rated, best anime β€” leave empty = always fire" style="grid-column:1/-1;"/>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<label style="font-size:12px;margin:0;display:flex;align-items:center;gap:4px;white-space:nowrap;">Poll every
<input type="number" id="api-src-interval" value="24" min="1" max="720" style="width:52px;font-size:12px;padding:4px 6px;text-align:center;"/> h
</label>
<button onclick="addApiSource()" style="width:auto;margin:0;padding:8px 16px;">πŸ’Ύ Save Source</button>
</div>
<div id="api-src-status" style="font-size:11px;margin-top:6px;color:var(--text-muted);"></div>
</div>
</div>
</div>
</div>
<!-- 6. FAQ Manager -->
<div class="card" id="card-faq">
<div class="card-header" onclick="toggle('faq')"><h2>πŸ’¬ FAQ Manager</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<p style="color:var(--text-muted);font-size:13px;margin-bottom:16px;">Manually add Q&amp;A pairs the bot can always answer β€” even if they're not on the website. These get embedded directly into the active DB.</p>
<input type="text" id="faq-q" placeholder="Question e.g. Is this for beginners?" style="margin-bottom:8px;"/>
<textarea id="faq-a" placeholder="Answer..." style="height:80px;margin-bottom:8px;"></textarea>
<button onclick="addFaq()">βž• Add FAQ</button>
<div id="faq-status" class="status-msg" style="margin:8px 0;"></div>
<div id="faq-list" style="margin-top:16px;"></div>
</div>
</div>
<!-- 6b. Customer Leads -->
<div class="card" id="card-leads">
<div class="card-header" onclick="toggle('leads')"><h2>πŸ“₯ Customer Leads</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<p style="color:var(--text-muted);font-size:13px;margin-bottom:12px;">Contact details visitors left in the chat. Newest first β€” also sent to your contact email.</p>
<button onclick="loadLeads()">πŸ”„ Load Leads</button>
<div id="leads-status" class="status-msg" style="margin:8px 0;"></div>
<div id="leads-list" style="max-height:420px;overflow-y:auto;"></div>
</div>
</div>
<!-- 7. Behavioral Suite -->
<div class="card" id="card-tests">
<div class="card-header" onclick="toggle('tests')"><h2>πŸ›‘οΈ Production Behavioral Suite</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<p style="font-size:12px; color:var(--text-muted); margin-bottom:15px;">Conduct targeted or full-system audits to verify Agni's integrity and safety boundaries.</p>
<div class="grid-2" style="margin-bottom:12px;">
<button onclick="runAudit('identity')" class="secondary" style="margin:0; font-size:11px;">πŸ†” Identity Audit</button>
<button onclick="runAudit('knowledge')" class="secondary" style="margin:0; font-size:11px;">🧠 Knowledge Audit</button>
</div>
<div class="grid-2" style="margin-bottom:12px;">
<button onclick="runAudit('safety')" class="secondary" style="margin:0; font-size:11px;">πŸ›‘οΈ Safety Audit</button>
<button onclick="runAudit('live')" class="secondary" style="margin:0; font-size:11px;">🌐 Live API Audit</button>
</div>
<div class="grid-2" style="margin-bottom:20px;">
<button onclick="runAudit('admin-check')" class="secondary" style="margin:0; font-size:11px;">πŸ”§ Admin Audit</button>
<button onclick="runAudit('all')" style="margin:0; font-size:11px; background:var(--text-main);">πŸš€ Full System Audit</button>
</div>
<div id="audit-list" style="margin-top:20px; display:none;">
<table style="border:1px solid var(--border); border-radius:12px; overflow:hidden;">
<thead style="background:#f8fafc;"><tr><th>Audit Metric</th><th>Status</th><th>Technical Explanation</th></tr></thead>
<tbody id="audit-body"></tbody>
</table>
</div>
<div id="tests-status" class="status-msg"></div>
</div>
</div>
<!-- 8. RAG Evals & Scoreboard (Owner-only) -->
<div class="card" id="card-evals">
<div class="card-header" onclick="toggle('evals')"><h2>πŸ§ͺ RAG Evals & Scoreboard</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<p style="font-size:12px; color:var(--text-muted); margin-bottom:14px;">
One-click quality checks for a tenant DB. Scores are split into Retrieval, Answer, and IDK/Refusal so you can see what broke.
</p>
<p style="font-size:12px; color:var(--text-muted); margin-bottom:14px;">
LLM judging uses a separate judge key from Owner Ops. If no judge key is configured, evals still run with deterministic fallback diagnosis, but confidence is lower.
</p>
<div class="grid-2" style="align-items:end;">
<div>
<label>Tenant DB</label>
<select id="eval-db"></select>
</div>
<div class="grid-2" style="gap:10px;">
<div>
<label>Mode</label>
<select id="eval-mode">
<option value="retrieve">Retrieve only (Cheap)</option>
<option value="chat">Chat (More accurate)</option>
<option value="both">Both</option>
</select>
</div>
<div>
<label>Questions</label>
<input id="eval-count" type="number" min="8" max="20" value="12"/>
</div>
</div>
</div>
<div class="grid-2" style="margin-top:10px; gap:10px;">
<button class="secondary" style="margin:0;" onclick="generateEvalSet('analytics')">Generate from Analytics (Free)</button>
<button class="secondary" style="margin:0;" onclick="generateEvalSet('kb')">Generate from KB (Uses keys)</button>
</div>
<div style="display:flex; gap:10px; margin-top:12px; align-items:center;">
<button style="margin:0;flex:1;" onclick="runEvalSuite()">Run Eval Suite</button>
<button class="secondary" style="margin:0;flex:1;" id="btn-run-history" onclick="runFromHistory()" title="Run all saved questions for this DB">Run from Saved (<span id="history-count">0</span>)</button>
</div>
<div id="eval-judge-warning" class="eval-fact" style="margin-top:10px;background:#fff7ed;border-color:#fed7aa;color:#9a3412;">
Chat eval judge: checking Owner Ops judge key...
</div>
<div id="eval-status" class="status-msg"></div>
<div id="eval-scores" style="display:none; margin-top:14px;">
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<span class="eval-pill" title="Weighted summary score (0-10)."><span>Overall</span><span id="score-overall" style="margin-left:auto;font-weight:900;"></span></span>
<span class="eval-pill" title="Did we retrieve the expected source / relevant chunks?"><span>Retrieval</span><span id="score-retrieval" style="margin-left:auto;font-weight:900;"></span></span>
<span class="eval-pill" title="Given the retrieved context, did the bot include the key facts?"><span>Answer</span><span id="score-answer" style="margin-left:auto;font-weight:900;"></span></span>
<span class="eval-pill" title="For unanswerable tests, did the bot refuse or say it doesn't know?"><span>IDK</span><span id="score-idk" style="margin-left:auto;font-weight:900;"></span></span>
</div>
<div id="eval-meaning" style="margin-top:10px; font-size:12px; color:var(--text-muted); line-height:1.5;"></div>
<div id="eval-summary" class="eval-summary-grid" style="display:none;"></div>
</div>
<div id="eval-results" style="display:none; margin-top:12px;">
<table style="border:1px solid var(--border); border-radius:12px; overflow:hidden;">
<thead style="background:#f8fafc;">
<tr>
<th>Difficulty</th>
<th>Question</th>
<th>Retrieval</th>
<th>Answer</th>
<th>IDK</th>
<th>Diagnosis</th>
<th></th>
</tr>
</thead>
<tbody id="eval-body"></tbody>
</table>
</div>
</div>
</div>
<!-- 7. Embed Widget -->
<div class="card" id="card-embed">
<div class="card-header" onclick="toggle('embed')"><h2>πŸ“¦ Embed on Your Website</h2><div class="card-arrow">β–Ό</div></div>
<div class="card-body">
<p style="font-size:13px; color:var(--text-muted); margin-bottom:20px;">Add this bot to <strong>Shopify, WooCommerce, WordPress or any HTML website</strong> with one stable line. Key rotation is handled automatically.</p>
<label>Your Embed Snippet</label>
<div style="position:relative;">
<textarea id="embed-snippet" readonly style="font-family:monospace; font-size:12px; background:#1e293b; color:#f8fafc; border-radius:12px; padding:16px 60px 16px 16px; height:80px; resize:none; border:none; width:100%; line-height:1.6;"></textarea>
<button onclick="copyEmbed()" style="position:absolute; top:10px; right:10px; width:auto; padding:6px 14px; margin:0; font-size:11px; background:var(--primary);">Copy</button>
</div>
<div style="background:#f0fdf4; border:1.5px solid #86efac; border-radius:12px; padding:14px; margin-top:16px; font-size:12px; color:#166534; line-height:1.7;">
<strong>How it works:</strong><br>
1. Copy the snippet above<br>
2. Paste it into any HTML page before &lt;/body&gt;<br>
3. A floating chat button appears β€” customers can click it to chat<br>
4. Works on WordPress, Webflow, Shopify, plain HTML β€” anywhere
</div>
<div style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap;">
<button onclick="loadEmbedCode()" class="secondary" style="margin:0; width:auto;">πŸ”„ Refresh Snippet</button>
<button onclick="previewBot()" style="margin:0; width:auto; background:#10b981;">πŸ€– Preview Your Bot</button>
</div>
<div style="display:none;">
<select id="widget-ttl" style="width:auto; min-width:160px; margin:0;"
onchange="document.getElementById('widget-ttl-days').style.display = this.value==='custom' ? 'inline-block' : 'none';">
<option value="">Select key lifetime</option>
<option value="30d">30 days</option>
<option value="7d">7 days</option>
<option value="1d">1 day</option>
<option value="custom">Custom (days)…</option>
<option value="lifetime">Lifetime</option>
<option value="1m">1 minute (test)</option>
</select>
<input id="widget-ttl-days" type="number" min="1" max="3650" placeholder="days e.g. 12"
style="display:none; width:120px; margin:0;">
<button onclick="createWidgetKey()" style="margin:0; width:auto; background:#2563eb;">πŸ”‘ Create Widget Key</button>
</div>
<div id="widget-key-meta" style="margin-top:10px; font-size:12px; color:var(--text-muted);"></div>
<div id="embed-status" class="status-msg"></div>
</div>
</div>
<!-- Client-only preview button (shown instead of full embed card) -->
<div id="client-preview-section" class="card">
<div class="card-body" style="padding:20px; text-align:center;">
<button onclick="previewBot()" style="background:#10b981; margin:0; width:100%;">πŸ€– Preview Your Bot</button>
</div>
</div>
<div id="owner-only-cache" style="display:none"></div>
</div>
<script>
// AUTOFILL NUCLEAR FIX: Use contenteditable divs β€” Chrome's credential manager ONLY
// targets <input> elements. contenteditable is immune to all browser autofill.
let _rawPassword = '';
let _rawCursor = 0;
function _updatePassDisplay() {
const disp = document.getElementById('master-pass-display');
const hidden = document.getElementById('master-pass');
const text = (disp && disp.dataset.showing !== '1') ? '●'.repeat(_rawPassword.length) : _rawPassword;
if (disp) {
disp.textContent = text;
if (document.activeElement === disp) {
try {
const range = document.createRange();
const sel = window.getSelection();
const pos = Math.min(_rawCursor, text.length);
if (disp.firstChild) { range.setStart(disp.firstChild, pos); } else { range.setStart(disp, 0); }
range.collapse(true); sel.removeAllRanges(); sel.addRange(range);
} catch(ex) {}
}
}
if (hidden) hidden.value = _rawPassword;
}
function _handlePassKeydown(e) {
if (e.key === 'Enter') { e.preventDefault(); unlockSystem(); return; }
if (e.key === 'Backspace') {
e.preventDefault();
if (_rawCursor > 0) { _rawPassword = _rawPassword.slice(0, _rawCursor - 1) + _rawPassword.slice(_rawCursor); _rawCursor--; }
_updatePassDisplay(); return;
}
if (e.key === 'Delete') {
e.preventDefault();
if (_rawCursor < _rawPassword.length) { _rawPassword = _rawPassword.slice(0, _rawCursor) + _rawPassword.slice(_rawCursor + 1); }
else { _rawPassword = ''; _rawCursor = 0; }
_updatePassDisplay(); return;
}
if (e.key === 'ArrowLeft') { e.preventDefault(); if (_rawCursor > 0) _rawCursor--; _updatePassDisplay(); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); if (_rawCursor < _rawPassword.length) _rawCursor++; _updatePassDisplay(); return; }
if (e.key === 'Home') { e.preventDefault(); _rawCursor = 0; _updatePassDisplay(); return; }
if (e.key === 'End') { e.preventDefault(); _rawCursor = _rawPassword.length; _updatePassDisplay(); return; }
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
_rawPassword = _rawPassword.slice(0, _rawCursor) + e.key + _rawPassword.slice(_rawCursor);
_rawCursor++; _updatePassDisplay();
}
}
(function() {
const inputStyle = 'width:100%;border:1.5px solid #e2e8f0;border-radius:10px;padding:10px 14px;font-size:14px;outline:none;min-height:42px;line-height:20px;cursor:text;background:#fff;color:#1e293b;box-sizing:border-box;';
// DB field β€” contenteditable div
const dbC = document.getElementById('admin-db-container');
if (dbC) {
const div = document.createElement('div');
div.id = 'admin-db'; div.contentEditable = 'true';
div.style.cssText = inputStyle;
div.setAttribute('role', 'textbox'); div.setAttribute('aria-label', 'Database name');
// Expose .value so all existing code that reads admin-db.value still works
Object.defineProperty(div, 'value', {
get() { return this.textContent.trim(); },
set(v) { this.textContent = v; },
configurable: true
});
div.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); unlockSystem(); } });
div.addEventListener('paste', e => {
e.preventDefault();
const t = (e.clipboardData || window.clipboardData).getData('text/plain').split('\n')[0].trim();
div.textContent = t;
});
div.addEventListener('focus', () => div.style.borderColor = '#6366f1');
div.addEventListener('blur', () => div.style.borderColor = '#e2e8f0');
dbC.appendChild(div);
}
// Password field β€” contenteditable div with bullet masking
const pwC = document.getElementById('master-pass-container');
if (pwC) {
const disp = document.createElement('div');
disp.id = 'master-pass-display'; disp.contentEditable = 'true';
disp.style.cssText = inputStyle + 'padding-right:36px;font-size:20px;letter-spacing:3px;';
disp.setAttribute('role', 'textbox'); disp.setAttribute('aria-label', 'Password');
disp.addEventListener('keydown', _handlePassKeydown);
disp.addEventListener('paste', e => {
e.preventDefault();
const t = (e.clipboardData || window.clipboardData).getData('text/plain');
_rawPassword = _rawPassword.slice(0, _rawCursor) + t + _rawPassword.slice(_rawCursor);
_rawCursor += t.length; _updatePassDisplay();
});
disp.addEventListener('mouseup', () => {
const sel = window.getSelection();
if (sel && sel.rangeCount) _rawCursor = Math.min(sel.getRangeAt(0).startOffset, _rawPassword.length);
});
disp.addEventListener('focus', () => disp.style.borderColor = '#6366f1');
disp.addEventListener('blur', () => disp.style.borderColor = '#e2e8f0');
pwC.appendChild(disp);
// Hidden input keeps the .value API working for all existing code
const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.id = 'master-pass'; hidden.value = '';
pwC.appendChild(hidden);
}
})();
const API = window.location.origin;
localStorage.removeItem('adminPass'); // legacy cleanup: passwords now live only for the tab session
let feedbackChart = null;
// ── Custom confirm dialog (replaces browser confirm()) ──
let confirmResolve = () => {};
function showConfirm(title, msg, okLabel = 'Confirm', danger = true) {
return new Promise(resolve => {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-msg').textContent = msg;
const btn = document.getElementById('confirm-ok-btn');
btn.textContent = okLabel;
btn.className = 'btn-ok' + (danger ? '' : ' safe');
document.getElementById('confirm-overlay').classList.add('active');
confirmResolve = (result) => {
document.getElementById('confirm-overlay').classList.remove('active');
resolve(result);
};
});
}
// Close on backdrop click
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('confirm-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('confirm-overlay')) confirmResolve(false);
});
});
window._crawlPollState = window._crawlPollState || {
db_name: '',
pass: '',
offset: 0,
running: false,
token: 0,
};
// When user returns to tab: resume polling if a crawl was in progress
document.addEventListener('visibilitychange', async () => {
if (document.hidden) return;
const db_name = window._activeCrawlDb;
const pass = window._activeCrawlPass;
if (!db_name || !pass) return;
if (window._crawlPollState?.running) return;
// Check server β€” if still running, resume polling UI
try {
const resumeOffset = Number(window._crawlPollState?.offset || 0);
const r = await fetch(`${API}/admin/crawl-status?db_name=${encodeURIComponent(db_name)}&password=${encodeURIComponent(pass)}&offset=${resumeOffset}`);
if (!r.ok) return;
const data = await r.json();
if (!data.running && !data.done) return;
// Rebuild log UI and resume poll
const statusEl = document.getElementById('crawl-status');
if (statusEl && !document.getElementById('crawl-logs')) {
statusEl.innerHTML = '<div style="margin-bottom:10px;display:flex;align-items:center;gap:10px;">πŸ”„ Reconnected β€” crawl still running... <button onclick="cancelActiveCrawl()" style="background:#dc2626;color:#fff;border:none;padding:3px 10px;border-radius:8px;font-size:11px;cursor:pointer;">⏹ Cancel</button></div><div id="crawl-logs" style="font-family:monospace; font-size:10px; background:#1e293b; color:#86efac; padding:12px; border-radius:12px; max-height:200px; overflow-y:auto; line-height:1.4;"></div>';
}
const logsEl = document.getElementById('crawl-logs');
if (logsEl && !data.done) {
window._crawlPollState = {
db_name,
pass,
offset: resumeOffset,
running: false,
token: Number(window._crawlPollState?.token || 0),
};
if (window._crawlReconnectInterval) { clearInterval(window._crawlReconnectInterval); window._crawlReconnectInterval = null; }
_pollCrawlStatus(db_name, pass, logsEl, statusEl);
}
} catch (_) {}
});
function showStatus(id, msg, type, persistent = false) {
const el = document.getElementById(id + '-status');
if (!el) return;
el.textContent = msg;
el.className = 'status-msg status-' + type;
el.style.display = 'block';
if (el._timeout) clearTimeout(el._timeout);
if (type === 'success' && !persistent) {
el._timeout = setTimeout(() => { el.style.display = 'none'; }, 8000);
}
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}
function escapeJsString(value) {
return String(value ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, ' ');
}
function appendSafeLog(el, msg) {
if (!el) return;
const div = document.createElement('div');
div.textContent = String(msg ?? '');
el.appendChild(div);
el.scrollTop = el.scrollHeight;
}
function authHeaders() {
const pass = sessionStorage.getItem('adminPass') || '';
const csrf = sessionStorage.getItem('csrfToken') || '';
const db = localStorage.getItem('adminDb') || '';
return { 'Authorization': `Bearer ${pass}`, 'X-CSRF-Token': csrf, 'X-Admin-DB': db };
}
// Refresh CSRF token silently β€” call before any write operation if token may be stale
async function refreshCsrf() {
const pass = sessionStorage.getItem('adminPass') || '';
const db = localStorage.getItem('adminDb') || '';
try {
const r = await fetch(`${API}/admin/csrf-token`, { headers: { 'Authorization': `Bearer ${pass}`, 'X-Admin-DB': db } });
if (r.ok) { const d = await r.json(); sessionStorage.setItem('csrfToken', d.csrf_token); }
} catch(e) {}
}
// Fetch wrapper that auto-refreshes CSRF on 403 and retries once
async function authFetch(url, opts = {}) {
let res = await fetch(url, opts);
if (res.status === 403) {
const body = await res.clone().json().catch(() => ({}));
if ((body.detail || '').toLowerCase().includes('csrf')) {
await refreshCsrf();
opts.headers = { ...opts.headers, 'X-CSRF-Token': sessionStorage.getItem('csrfToken') || '' };
res = await fetch(url, opts);
}
}
return res;
}
async function cancelActiveCrawl() {
const db_name = window._activeCrawlDb || localStorage.getItem('adminDb') || '';
if (!db_name) return;
const btn = document.getElementById('crawl-stop-btn');
if (btn) { btn.textContent = 'stopping...'; btn.style.pointerEvents = 'none'; }
try {
const res = await authFetch(`${API}/admin/crawl/cancel`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({db_name, password: sessionStorage.getItem('adminPass') || ''})
});
const d = await res.json();
if (btn) btn.textContent = d.status === 'cancelled' ? 'stopped' : 'not running';
showStatus('crawl', d.status === 'cancelled' ? '\u2705 Crawl cancelled' : '\u26a0\ufe0f No crawl running', d.status === 'cancelled' ? 'success' : 'warning');
} catch(e) {
if (btn) { btn.textContent = 'stop crawl'; btn.style.pointerEvents = 'auto'; }
showStatus('crawl', '\u274c Cancel failed', 'error');
}
}
async function cancelDbCrawl(dbName) {
const nextEl = document.getElementById(`next-crawl-${dbName}`);
const cancelEl = document.getElementById(`cancel-crawl-${dbName}`);
// Immediate feedback
if (nextEl) nextEl.textContent = '\u23f8 Stopping...';
if (cancelEl) cancelEl.style.display = 'none';
try {
const res = await authFetch(`${API}/admin/crawl/cancel`, {
method: 'POST', headers: {...authHeaders(), 'X-Admin-DB': dbName, 'Content-Type': 'application/json'},
body: JSON.stringify({db_name: dbName, password: sessionStorage.getItem('adminPass') || ''})
});
const d = await res.json();
if (nextEl) nextEl.textContent = d.status === 'cancelled' ? '\u2705 Stopped' : '\u26a0\ufe0f Not running';
// After 3s reset to countdown mode so the timer takes over
setTimeout(() => {
if (nextEl) { nextEl.dataset.crawling = '0'; nextEl.textContent = '\u23ed Next: computing...'; }
}, 3000);
} catch(e) {
if (nextEl) { nextEl.dataset.crawling = '1'; nextEl.textContent = '\ud83d\udd04 Recrawling now...'; }
if (cancelEl) cancelEl.style.display = 'inline';
}
}
function unlockSystem(passArg, dbArg) {
// Auto-login path: sync _rawPassword + hidden input so subsequent .value reads work
if (passArg) { _rawPassword = passArg; const h = document.getElementById('master-pass'); if (h) h.value = passArg; }
const pass = passArg || _rawPassword;
const dbEl = document.getElementById('admin-db');
const db = dbArg || (dbEl ? dbEl.value : '') || '';
if (!pass) return showStatus('auth', 'Password required.', 'error');
if (!db) return showStatus('auth', 'DB name required.', 'error');
showStatus('auth', 'Authenticating...', 'loading');
fetch(`${API}/admin/branding`, { headers: { 'Authorization': `Bearer ${pass}`, 'X-Admin-DB': db } }).then(async r => {
if (!r.ok) { sessionStorage.removeItem('adminPass'); sessionStorage.removeItem('csrfToken'); localStorage.removeItem('adminDb'); const eb = await r.json().catch(() => ({})); showStatus('auth', (r.status === 404 && eb.detail) ? eb.detail : 'Denied. Wrong password or DB name.', 'error'); return; }
const data = await r.json();
sessionStorage.setItem('adminPass', pass);
localStorage.setItem('adminDb', db);
// Save DB name to this browser's local history for dropdown
const hist = JSON.parse(localStorage.getItem('adminDbHistory') || '[]');
if (!hist.includes(db)) { hist.unshift(db); localStorage.setItem('adminDbHistory', JSON.stringify(hist.slice(0, 10))); }
// Fetch CSRF token for state-changing requests
const csrfResp = await fetch(`${API}/admin/csrf-token`, { headers: { 'Authorization': `Bearer ${pass}`, 'X-Admin-DB': db } });
if (csrfResp.ok) {
const csrfData = await csrfResp.json();
sessionStorage.setItem('csrfToken', csrfData.csrf_token);
}
// Determine owner vs client mode
const authModeResp = await fetch(`${API}/admin/auth-mode`, { headers: { 'Authorization': `Bearer ${pass}`, 'X-Admin-DB': db } });
if (authModeResp.ok) {
const authMode = await authModeResp.json();
// Logging into a DB makes it the one the bot serves. Owners set the global
// Active (AWAITED, so it never stays stale on agentfactory). Clients can't and
// needn't β€” their panels are X-Admin-DB scoped and their widget is key-scoped.
if (authMode.role === 'owner') {
try {
const sf = new FormData(); sf.append("password", pass); sf.append("name", db);
const setResp = await fetch(`${API}/admin/databases/set-active`, { method:"POST", headers: authHeaders(), body:sf });
const setData = await setResp.json().catch(() => ({}));
if (!setResp.ok || setData.success === false) {
showStatus('auth', setData.detail || setData.message || 'Login succeeded, but active DB switch failed.', 'error');
return;
}
} catch(_) {
showStatus('auth', 'Login succeeded, but active DB switch failed.', 'error');
return;
}
}
if (authMode.role === 'client') {
document.body.classList.add('client-mode');
sessionStorage.setItem('clientMode', '1');
} else {
document.body.classList.remove('client-mode');
sessionStorage.removeItem('clientMode');
}
}
document.body.classList.remove('locked');
document.querySelector('.auth-section').style.display = 'none';
showStatus('auth', `Logged into: ${db}`, 'success');
// Reset any prior tenant's widget snippet so the embed card / Preview
// never reuse a key baked for the DB you just logged out of.
window._widgetKeyReady = false;
const _es = document.getElementById('embed-snippet'); if (_es) _es.value = '';
const _km = document.getElementById('widget-key-meta'); if (_km) _km.textContent = '';
loadServerData(data);
loadOpsData(data.config_full || data);
loadDatabases();
loadEmbedCode();
loadCharts();
loadAnalytics();
updateHealthStatus();
startDetachedCrawlReconnectLoop(db, pass);
if (window._healthInterval) clearInterval(window._healthInterval);
// Poll only while the tab is actually visible β€” on scale-to-zero
// hosting an idle background tab would keep the container billed.
window._healthInterval = setInterval(() => { if (!document.hidden && _adminActive()) updateHealthStatus(); }, 300000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) updateHealthStatus(); });
}).catch(() => showStatus('auth', 'Failed.', 'error'));
}
function togglePassVis() {
const disp = document.getElementById('master-pass-display');
const icon = document.getElementById('eye-icon');
if (!disp) return;
const showing = disp.dataset.showing === '1';
if (showing) {
disp.dataset.showing = '0'; disp.style.fontSize = '20px'; disp.style.letterSpacing = '3px';
disp.textContent = '●'.repeat(_rawPassword.length);
icon.innerHTML = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>';
} else {
disp.dataset.showing = '1'; disp.style.fontSize = '14px'; disp.style.letterSpacing = 'normal';
disp.textContent = _rawPassword;
icon.innerHTML = '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>';
}
}
document.addEventListener('DOMContentLoaded', () => {
// Clear all session state on page load β€” require explicit login every time
sessionStorage.removeItem('clientMode');
sessionStorage.removeItem('csrfToken');
sessionStorage.removeItem('adminPass');
localStorage.removeItem('adminDb');
});
function toggle(id) {
if (document.body.classList.contains('locked')) return;
const card = document.getElementById('card-' + id); card.classList.toggle('open');
if (card.classList.contains('open')) {
if (id === 'db') loadDatabases();
if (id === 'stats') { loadAnalytics(); loadCharts(); }
if (id === 'embed') loadEmbedCode();
if (id === 'brand') loadBranding();
if (id === 'ops') loadOps();
if (id === 'ingest') loadApiSources();
if (id === 'evals') loadEvalDbs();
}
}
function loadServerData(d) {
const b = d.branding || {};
document.getElementById("b-bot-name").value = d.bot_name || "";
document.getElementById("b-biz-name").value = d.business_name || "";
document.getElementById("b-header-title").value = b.header_title || "";
document.getElementById("b-header-subtitle").value = b.header_subtitle || "";
document.getElementById("b-welcome").value = b.welcome_message || "";
document.getElementById("b-placeholder").value = b.input_placeholder || "";
document.getElementById("b-page-title").value = b.page_title || "";
document.getElementById("b-logo-emoji").value = b.logo_emoji || "";
document.getElementById("b-logo-url").value = b.logo_url || "";
document.getElementById("b-font-style").value = b.font_style || "Modern Sans";
document.getElementById("b-primary-hex").value = b.primary_color || "#6366f1";
document.getElementById("b-secondary-hex").value = b.secondary_color || "#4f46e5";
document.getElementById("b-bot-bubble-hex").value = b.bot_bubble_color || "#f1f5f9";
document.getElementById("b-user-bubble-hex").value = b.user_bubble_color || "#6366f1";
// Load secondary prompt
document.getElementById("b-secondary-prompt").value = d.secondary_prompt || "";
updatePreview();
}
async function loadKnowledgeGaps() {
const pass = document.getElementById('master-pass').value;
const el = document.getElementById('knowledge-gaps-list');
el.innerHTML = '<span style="color:#94a3b8;">Loading...</span>';
try {
const res = await fetch(`${API}/admin/knowledge-gaps`, { headers: authHeaders() });
const data = await res.json();
if (!data.length) { el.innerHTML = '<span style="color:#10b981;">No knowledge gaps recorded yet.</span>'; return; }
// Group by question
const freq = {};
data.forEach(g => { freq[g.question] = (freq[g.question] || 0) + 1; });
const sorted = Object.entries(freq).sort((a,b) => b[1]-a[1]).slice(0, 30);
window._gapQuestions = sorted.map(([q]) => q);
el.innerHTML = '<table style="width:100%;font-size:12px;"><tbody>' +
sorted.map(([q,c], idx) => {
const escaped = idx;
return `<tr>
<td style="color:var(--primary);font-weight:700;width:30px;padding:4px 8px 4px 0">${c}x</td>
<td style="padding:4px 0">${escapeHtml(q)}</td>
<td style="padding:4px 0 4px 8px"><button onclick="suggestAnswer(this,'${escaped}',${idx})" style="font-size:11px;padding:3px 8px;background:#f0fdf4;border:1px solid #86efac;border-radius:8px;cursor:pointer;color:#166534">πŸ’‘ Suggest</button></td>
</tr>
<tr id="suggest-row-${idx}" style="display:none">
<td colspan="3" style="padding:8px 0">
<textarea id="suggest-text-${idx}" style="width:100%;font-size:12px;padding:8px;border:1.5px solid #6366f1;border-radius:8px;min-height:60px;box-sizing:border-box"></textarea>
<button onclick="addGapToKb(${idx})" style="font-size:12px;padding:5px 12px;background:#6366f1;color:white;border:none;border-radius:8px;cursor:pointer;margin-top:4px">Add to KB</button>
<button onclick="document.getElementById('suggest-row-${idx}').style.display='none'" style="font-size:12px;padding:5px 8px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;cursor:pointer;margin:4px 0 0 6px">βœ•</button>
</td>
</tr>`;
}).join('') +
'</tbody></table>';
} catch (e) {
el.innerHTML = '<span style="color:#ef4444;">Failed to load. Check your password.</span>';
}
}
async function suggestAnswer(btn, question, idx) {
idx = Number.isInteger(idx) ? idx : Number(question);
question = (window._gapQuestions || [])[idx] || '';
btn.textContent = '...';
const pass = document.getElementById('master-pass').value;
try {
const r = await authFetch(`${API}/admin/knowledge-gaps/suggest`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({password: pass, question})
});
const d = await r.json();
btn.textContent = 'πŸ’‘ Suggest';
const row = document.getElementById(`suggest-row-${idx}`);
if (row) {
row.style.display = '';
const ta = document.getElementById(`suggest-text-${idx}`);
if (ta) ta.value = d.suggestion || d.detail || '';
}
} catch (e) {
btn.textContent = 'πŸ’‘ Suggest';
}
}
async function addGapToKb(idx) {
const ta = document.getElementById(`suggest-text-${idx}`);
if (!ta || !ta.value.trim()) return;
const pass = document.getElementById('master-pass').value;
const r = await authFetch(`${API}/admin/ingest/text`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({password: pass, text: ta.value.trim(), target_db: localStorage.getItem('adminDb') || ''})
});
const d = await r.json();
showStatus('stats', d.success ? 'Added to KB!' : (d.detail || 'Failed'), d.success ? 'success' : 'error');
if (d.success) {
const row = document.getElementById(`suggest-row-${idx}`);
if (row) row.style.display = 'none';
}
}
async function loadAnalytics() {
const pass = document.getElementById('master-pass').value;
showStatus('stats', 'Syncing analytics...', 'loading');
try {
const res = await fetch(`${API}/admin/analytics`, { headers: authHeaders() });
const data = await res.json();
document.getElementById('stat-total').textContent = data.total_queries ?? data.total ?? 0;
document.getElementById('stat-csat').textContent = data.avg_csat != null ? `${Number(data.avg_csat).toFixed(1)}/5` : 'β€”';
document.getElementById('stat-extra').textContent = `Sessions: ${Number(data.total_sessions || 0)} Β· Ratings: ${Number(data.total_ratings || 0)}`;
document.getElementById('stat-most-asked').innerHTML = data.most_asked.map(q => `
<tr><td style="color:var(--primary); font-weight:800; width:40px;">${Number(q.count || 0)}x</td><td>${escapeHtml(q.q)}</td></tr>
`).join('') || '<tr><td colspan="2">No data yet.</td></tr>';
document.getElementById('stat-most-recent').innerHTML = data.most_recent.map(h => `
<tr><td style="color:var(--text-muted); width:80px;">${escapeHtml(new Date(h.t).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}))}</td><td>${escapeHtml(h.q)}</td></tr>
`).join('') || '<tr><td colspan="2">No data yet.</td></tr>';
showStatus('stats', 'Analytics synced.', 'success');
} catch (e) {
showStatus('stats', 'Failed to load analytics.', 'error');
}
}
let _charts = {};
async function loadCharts() {
const pass = document.getElementById('master-pass').value;
try {
const r = await fetch(`${API}/admin/analytics-charts`, { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
const shortLabels = d.labels.map(l => l.slice(5));
Object.values(_charts).forEach(c => c.destroy());
_charts = {};
const opts = { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } };
_charts.queries = new Chart(document.getElementById('chart-queries'), {
type: 'line',
data: { labels: shortLabels, datasets: [{ label: 'Queries/day', data: d.daily_queries, borderColor: '#6366f1', backgroundColor: 'rgba(99,102,241,0.08)', fill: true, tension: 0.3, pointRadius: 3 }] },
options: { ...opts, plugins: { legend: { display: true, labels: { font: { size: 11 } } } } }
});
_charts.csat = new Chart(document.getElementById('chart-csat'), {
type: 'line',
data: { labels: shortLabels, datasets: [{ label: 'Avg CSAT', data: d.csat_avg, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.08)', fill: true, tension: 0.3, pointRadius: 3 }] },
options: { ...opts, plugins: { legend: { display: true, labels: { font: { size: 11 } } } }, scales: { y: { min: 0, max: 5, beginAtZero: true } } }
});
if (d.gap_labels.length > 0) {
_charts.gaps = new Chart(document.getElementById('chart-gaps'), {
type: 'bar',
data: { labels: d.gap_labels, datasets: [{ label: 'Unanswered', data: d.gap_counts, backgroundColor: '#f87171' }] },
options: { ...opts, indexAxis: 'y', plugins: { legend: { display: false } } }
});
}
} catch (e) { /* charts not critical */ }
}
async function loadDatabases() {
const pass = document.getElementById('master-pass').value;
const [res, statsRes] = await Promise.all([
fetch(`${API}/admin/databases`, { headers: authHeaders() }),
fetch(`${API}/admin/db-stats`, { headers: authHeaders() }).catch(()=>({json:()=>({})}))
]);
const data = await res.json();
const statsData = await statsRes.json().catch(()=>({}));
const statsMap = {};
(statsData.stats || []).forEach(s => statsMap[s.name] = s);
const isClient = document.body.classList.contains('client-mode');
const sessionDb = localStorage.getItem('adminDb') || 'β€”';
document.getElementById('active-ingest-db').textContent = sessionDb;
const sel = document.getElementById('ingest-target-db');
const prev = sel.value;
sel.innerHTML = '<option value="">β€” Session DB β€”</option>' +
(data.databases || []).map(db => `<option value="${db.name}"${db.name===prev?' selected':''}>${db.name}</option>`).join('');
if (prev) sel.value = prev;
let dbs = data.databases || [];
if (isClient) dbs = dbs.filter(db => db.name === sessionDb);
document.getElementById("db-list").innerHTML = dbs.map(db => {
const st = statsMap[db.name] || {};
const lastCrawl = (st.last_crawl_completed || st.last_crawl_time) ? new Date(st.last_crawl_completed || st.last_crawl_time).toLocaleString() : 'β€”';
const nextCrawlTs = st.next_crawl_ts || '';
const isCrawling = st.is_crawling || false;
const hasCrawl = st.auto_crawl_enabled && st.crawl_url;
const hasApi = st.api_sources?.length > 0;
const intervalM = st.crawl_interval_minutes || 60;
const intervalLabel = intervalM < 60 ? `${intervalM}min` : `${Number((intervalM/60).toFixed(1))}h`;
const health = st.health || {level:'empty', label:'Unknown', reason:'No data'};
const lastErr = st.last_crawl_error || '';
const retryAfter = st.last_crawl_retry_after || '';
const healthDot = {healthy:'🟒', warning:'🟑', critical:'πŸ”΄', empty:'βšͺ'}[health.level] || 'βšͺ';
const autoIcon = hasCrawl && hasApi ? 'πŸŸ’πŸ”Œ' : hasCrawl ? '🟒' : hasApi ? 'πŸ”Œ' : 'β­•';
const syncLabel = hasCrawl && hasApi
? `re-crawls every ${intervalLabel} + ${st.api_sources.length} live API source${st.api_sources.length>1?'s':''}`
: hasCrawl ? `re-crawls every ${intervalLabel} (new pages only)`
: hasApi ? `${st.api_sources.length} live API source${st.api_sources.length>1?'s':''}`
: 'no auto-sync';
const crawlUrl = st.crawl_url || '';
const newChunks = st.last_crawl_chunks || 0;
const lastCrawlLabel = lastCrawl !== 'β€”' ? `${lastCrawl} (+${newChunks} new chunks)` : 'β€”';
return `
<div class="db-item" id="db-${db.name}">
<div class="db-row">
<div onclick="expandDb('${db.name}')" style="cursor:pointer;flex:1;">
<div style="font-weight:800;font-size:14px;color:var(--text-main);">${db.name}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:3px;display:flex;gap:16px;flex-wrap:wrap;align-items:center;">
<span id="health-dot-${db.name}" title="${health.reason}">${healthDot} ${health.label}</span>
<span id="chunks-${db.name}">πŸ“¦ ${(st.chunks||db.chunks||0).toLocaleString()} chunks</span>
${(() => { const cc = st.collection_coverage; if (!cc || !cc.total) return ''; const miss = (cc.missing||[]); return cc.complete
? `<span title="All ${cc.total} storefront collections captured" style="color:#16a34a;font-weight:700;">πŸ—‚ ${cc.fetched}/${cc.total} collections</span>`
: `<span title="Store throttled ingest β€” ${miss.length} sidebar categories missing: ${escapeHtml(miss.slice(0,15).join(', '))}. Re-ingest to fill." style="color:#b45309;font-weight:700;">⚠ ${cc.fetched}/${cc.total} collections (${miss.length} missing)</span>`; })()}
<span>${autoIcon} ${syncLabel}</span>
${hasCrawl ? `<span id="last-crawl-label-${db.name}">πŸ• Last: ${lastCrawlLabel}</span><span id="next-crawl-${db.name}" data-ts="${nextCrawlTs}">${isCrawling ? 'πŸ”„ Recrawling now...' : nextCrawlTs ? '⏭ Next: computing...' : '⏭ Next: due now'}</span><span id="cancel-crawl-${db.name}" style="display:${isCrawling?'inline':'none'}"> Β· <a href="#" onclick="event.preventDefault();cancelDbCrawl('${db.name}')" style="color:#ef4444;font-size:10px;text-decoration:none;" title="Stop after current page finishes (up to 15s)">stop crawl</a></span>` : ''}
</div>
</div>
<div class="db-actions">
${!isClient ? (db.active ? `<span style="font-size:11px;color:#10b981;font-weight:700;padding:6px 12px;">βœ“ Active</span>` : `<button class="outline" onclick="setActiveDb('${db.name}')" style="width:auto;margin:0;padding:6px 12px;font-size:11px;">Set Test Active</button>`) : ''}
<div style="position:relative;display:inline-block;">
<button class="outline" onclick="toggleDbMenu('${db.name}',event)" style="width:auto;margin:0;padding:6px 12px;font-size:11px;">Edit β–Ύ</button>
<div id="db-menu-${db.name}" style="display:none;position:fixed;background:#fff;border:1.5px solid #e2e8f0;border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,0.12);z-index:9999;min-width:175px;overflow:hidden;">
<div onclick="renameDb('${db.name}')" style="padding:9px 16px;cursor:pointer;font-size:13px;color:#1e293b;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background=''">✏️ Change Name</div>
<div onclick="changeDbPass('${db.name}')" style="padding:9px 16px;cursor:pointer;font-size:13px;color:#1e293b;border-top:1px solid #f1f5f9;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background=''">πŸ”‘ Change Password</div>
<div onclick="deleteDb('${db.name}')" style="padding:9px 16px;cursor:pointer;font-size:13px;color:#ef4444;border-top:1px solid #f1f5f9;" onmouseover="this.style.background='#fff8f8'" onmouseout="this.style.background=''">πŸ—‘ Delete</div>
</div>
</div>
</div>
</div>
<div class="db-details" id="details-${db.name}">
<div style="background:${{healthy:'#f0fdf4',warning:'#fffbeb',critical:'#fef2f2',empty:'#f8fafc'}[health.level]||'#f8fafc'};border:1px solid ${{healthy:'#86efac',warning:'#fcd34d',critical:'#fca5a5',empty:'#e2e8f0'}[health.level]||'#e2e8f0'};border-radius:10px;padding:12px 16px;margin-bottom:10px;display:flex;align-items:center;gap:12px;">
<span style="font-size:20px;">${healthDot}</span>
<div>
<div style="font-weight:700;font-size:13px;color:var(--text-main);">KB ${health.label}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px;">${health.reason}</div>
${lastErr ? `<div id="crawl-error-${db.name}" style="font-size:11px;color:#b91c1c;margin-top:6px;white-space:pre-wrap;">${escapeHtml(String(lastErr).slice(0,300))}</div>` : `<div id="crawl-error-${db.name}" style="display:none"></div>`}
${retryAfter ? `<div id="crawl-retry-${db.name}" style="font-size:10px;color:var(--text-muted);margin-top:4px;">Retry after: ${escapeHtml(new Date(retryAfter).toLocaleString())}</div>` : `<div id="crawl-retry-${db.name}" style="display:none"></div>`}
</div>
</div>
<div class="grid-2" style="margin-bottom:10px;">
<div><label>Storage Size</label><div style="font-weight:700;font-size:15px;">${db.size}</div></div>
<div><label>Knowledge Chunks</label><div style="font-weight:700;font-size:15px;">${(st.chunks||db.chunks||0).toLocaleString()}</div></div>
</div>
<div style="background:#f8fafc;border-radius:10px;padding:14px;margin-bottom:10px;border:1px solid var(--border);">
<div id="sched-title-${db.name}" data-ai="${st.auto_ingest?1:0}" style="font-weight:700;font-size:12px;margin-bottom:8px;color:var(--text-main);">${st.auto_ingest ? '⚑ Auto-Ingest (structured catalog)' : '⏰ Auto Re-Crawl Schedule'}</div>
<div id="sched-hint-${db.name}" style="font-size:10px;color:var(--text-muted);margin-bottom:6px;">${st.auto_ingest ? 'Product API detected β€” refreshes catalog (prices/stock/new items) deterministically; no HTML crawl. Prose pages: use manual crawl.' : ''}</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input type="url" id="crawl-url-${db.name}" placeholder="https://yoursite.com" value="${crawlUrl}" oninput="markScheduleDirty('${db.name}');scheduleDetect('${db.name}')" style="flex:1;min-width:180px;font-size:12px;padding:6px 10px;"/>
<label style="font-size:12px;margin:0;display:flex;align-items:center;gap:4px;white-space:nowrap;">Every
<input type="number" id="crawl-interval-${db.name}" value="${intervalM}" min="1" max="10080" oninput="markScheduleDirty('${db.name}')" style="width:60px;font-size:12px;padding:4px 6px;text-align:center;"/> min
</label>
<label style="font-size:12px;margin:0;display:flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" id="crawl-enabled-${db.name}" ${st.auto_crawl_enabled?'checked':''} onchange="markScheduleDirty('${db.name}')" style="width:auto;margin:0;"/> Enable
</label>
<button id="crawl-save-btn-${db.name}" onclick="saveCrawlSchedule('${db.name}')" style="width:auto;margin:0;padding:6px 12px;font-size:11px;">πŸ’Ύ Save</button>
</div>
<div id="schedule-status-${db.name}" style="font-size:11px;margin-top:6px;color:var(--text-muted);"></div>
</div>
<button onclick="clearDbData('${db.name}')" style="width:auto;margin:0;padding:5px 14px;font-size:11px;background:#fff8f8;color:#dc2626;border:1.5px solid #fca5a5;border-radius:10px;cursor:pointer;font-weight:700;">πŸ—‘ Clear All Data</button>
</div>
</div>`}).join('') ||
`<div style="text-align:center;color:var(--text-muted);padding:24px;">No databases found. Create one above.</div>`;
// mark currently-crawling spans so interval skips them
document.querySelectorAll('[id^="next-crawl-"]').forEach(el => {
if (el.textContent.includes('Recrawling')) el.dataset.crawling = '1';
});
startCrawlCountdowns();
}
// ── Idle-sleep ────────────────────────────────────────────────────────────
// The admin panel can stay open forever, but it must only talk to the Modal
// backend when the user is actively working (clicked, changed a field, typed,
// submitted) or a crawl is live. Otherwise every background poll resets Modal's
// scaledown window and keeps the serve container awake 24/7 β€” which burned
// compute credits with zero real clients. With this, an idle open tab makes
// ZERO requests after ~2 min, so Modal scales down and sleeps.
window._lastAdminInteraction = Date.now();
function _adminActive() { return (Date.now() - (window._lastAdminInteraction || 0)) < 120000; }
['click', 'change', 'submit', 'keydown'].forEach(ev =>
document.addEventListener(ev, () => { window._lastAdminInteraction = Date.now(); }, true));
let _crawlCountdownTimer = null;
let _crawlStatsRefreshTimer = null;
function startCrawlCountdowns() {
if (_crawlCountdownTimer) clearInterval(_crawlCountdownTimer);
_crawlCountdownTimer = setInterval(() => {
document.querySelectorAll('[id^="next-crawl-"]').forEach(el => {
if (el.dataset.crawling === '1') return; // static "recrawling" message
const ts = el.dataset.ts;
if (!ts) return;
const secs = Math.round((new Date(ts) - Date.now()) / 1000);
if (isNaN(secs) || secs <= 0) {
el.textContent = '⏭ Next: due now';
} else if (secs < 3600) {
el.textContent = `⏭ Next: in ${Math.floor(secs/60)}m ${secs%60}s`;
} else {
el.textContent = `⏭ Next: in ${Math.floor(secs/3600)}h ${Math.floor((secs%3600)/60)}m`;
}
});
}, 1000);
// Re-fetch db-stats every 30s β€” updates chunks, health, last crawl, countdown live
if (_crawlStatsRefreshTimer) clearInterval(_crawlStatsRefreshTimer);
_crawlStatsRefreshTimer = setInterval(async () => {
// Sleep unless actively working: skip the poll when the tab is hidden OR the
// user hasn't interacted recently. An idle open panel makes no requests, so
// Modal scales down. A click/change/keypress wakes it again immediately.
if (document.hidden || !_adminActive()) return;
try {
const res = await fetch(`${API}/admin/db-stats`, { headers: authHeaders() });
if (!res.ok) return;
const data = await res.json();
const _healthDots = {healthy:'🟒', warning:'🟑', critical:'πŸ”΄', empty:'βšͺ'};
(data.stats || []).forEach(st => {
// Countdown timestamp
const el = document.getElementById(`next-crawl-${st.name}`);
if (el && el.dataset.crawling !== '1' && st.next_crawl_ts) el.dataset.ts = st.next_crawl_ts;
// Chunk count
const chunksEl = document.getElementById(`chunks-${st.name}`);
if (chunksEl) chunksEl.textContent = `πŸ“¦ ${(st.chunks||0).toLocaleString()} chunks`;
// Health badge
const healthEl = document.getElementById(`health-dot-${st.name}`);
if (healthEl && st.health) {
const dot = _healthDots[st.health.level] || 'βšͺ';
healthEl.textContent = `${dot} ${st.health.label}`;
healthEl.title = st.health.reason || '';
}
// Last crawl label
const lastEl = document.getElementById(`last-crawl-label-${st.name}`);
if (lastEl && (st.last_crawl_completed || st.last_crawl_time)) {
const lastCrawl = new Date(st.last_crawl_completed || st.last_crawl_time).toLocaleString();
const newChunks = st.last_crawl_chunks || 0;
lastEl.textContent = `πŸ• Last: ${lastCrawl} (+${newChunks} new chunks)`;
// Crawl failure detail
const errEl = document.getElementById(`crawl-error-${st.name}`);
if (errEl) {
const e = st.last_crawl_error || '';
if (e) { errEl.style.display = 'block'; errEl.textContent = String(e).slice(0,300); }
else { errEl.style.display = 'none'; errEl.textContent = ''; }
}
const raEl = document.getElementById(`crawl-retry-${st.name}`);
if (raEl) {
const ra = st.last_crawl_retry_after || '';
if (ra) { raEl.style.display = 'block'; raEl.textContent = 'Retry after: ' + new Date(ra).toLocaleString(); }
else { raEl.style.display = 'none'; raEl.textContent = ''; }
}
}
// is_crawling: update badge + show/hide cancel button
const nextEl = document.getElementById(`next-crawl-${st.name}`);
if (nextEl) {
if (st.is_crawling) {
nextEl.textContent = 'πŸ”„ Recrawling now...';
nextEl.dataset.crawling = '1';
} else if (nextEl.dataset.crawling === '1') {
nextEl.dataset.crawling = '0';
if (st.next_crawl_ts) nextEl.dataset.ts = st.next_crawl_ts;
}
}
const cancelBtn = document.getElementById(`cancel-crawl-${st.name}`);
if (cancelBtn) cancelBtn.style.display = st.is_crawling ? 'inline' : 'none';
});
} catch(e) { /* silent */ }
}, 10000);
}
// Auto-Ingest detection: when the crawl URL points at a store with a structured
// product API (Shopify/Woo), offer deterministic catalog ingest instead of an
// HTML crawl. The detected flag is stored on the schedule-title dataset and sent
// with the schedule save.
let _detectTimers = {};
function scheduleDetect(dbName) {
clearTimeout(_detectTimers[dbName]);
_detectTimers[dbName] = setTimeout(() => detectPlatform(dbName), 700);
}
async function detectPlatform(dbName) {
const urlEl = document.getElementById(`crawl-url-${dbName}`);
const hintEl = document.getElementById(`sched-hint-${dbName}`);
if (!urlEl) return;
const url = urlEl.value.trim();
if (!url || url.length < 6) { applyAutoIngestUI(dbName, false, ''); return; }
if (hintEl) hintEl.textContent = 'Checking for product API…';
try {
const res = await authFetch(`${API}/admin/detect-platform?url=${encodeURIComponent(url)}`, {
headers: {...authHeaders(), 'X-Admin-DB': dbName}
});
const d = await res.json();
applyAutoIngestUI(dbName, !!d.has_product_api, d.platform || '');
} catch(e) { if (hintEl) hintEl.textContent = ''; }
}
function applyAutoIngestUI(dbName, on, platform) {
const titleEl = document.getElementById(`sched-title-${dbName}`);
const hintEl = document.getElementById(`sched-hint-${dbName}`);
if (!titleEl) return;
titleEl.dataset.ai = on ? '1' : '0';
titleEl.textContent = on
? `⚑ Auto-Ingest${platform ? ' ('+platform+')' : ' (structured catalog)'}`
: '⏰ Auto Re-Crawl Schedule';
if (hintEl) hintEl.textContent = on
? `${platform||'Product API'} detected β€” refreshes catalog (prices/stock/new items) deterministically; no HTML crawl. Prose pages: use manual crawl.`
: '';
}
function markScheduleDirty(dbName) {
const statusEl = document.getElementById(`schedule-status-${dbName}`);
if (statusEl && statusEl.dataset.saved === '1') {
statusEl.textContent = '';
statusEl.dataset.saved = '0';
}
}
async function saveCrawlSchedule(dbName) {
const pass = document.getElementById('master-pass').value;
const urlEl = document.getElementById(`crawl-url-${dbName}`);
const intervalEl = document.getElementById(`crawl-interval-${dbName}`);
const enabledEl = document.getElementById(`crawl-enabled-${dbName}`);
const statusEl = document.getElementById(`schedule-status-${dbName}`);
if (!urlEl || !intervalEl || !enabledEl || !statusEl) return;
const url = urlEl.value.trim();
const interval = parseFloat(intervalEl.value) || 1;
const enabled = enabledEl.checked;
const titleEl = document.getElementById(`sched-title-${dbName}`);
const autoIngest = !!(titleEl && titleEl.dataset.ai === '1');
statusEl.textContent = 'Saving...';
statusEl.dataset.saved = '0';
try {
const res = await authFetch(`${API}/admin/crawl-schedule`, {
method: 'POST', headers: {...authHeaders(), 'X-Admin-DB': dbName, 'Content-Type': 'application/json'},
body: JSON.stringify({password:pass, db_name:dbName, enabled, interval_minutes:interval, crawl_url:url, auto_ingest:autoIngest})
});
const d = await res.json();
if (d.success) {
statusEl.textContent = 'βœ“ Saved β€” refreshing...';
statusEl.style.color = '#16a34a';
statusEl.dataset.saved = '1';
await loadDatabases();
} else {
statusEl.textContent = d.detail || 'Failed';
statusEl.style.color = '#dc2626';
}
} catch(e) { statusEl.textContent = 'Connection error'; statusEl.style.color='#dc2626'; }
}
// --- GLOBAL KEY LISTENERS ---
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const active = document.activeElement;
if (!active) return;
if (active.id === 'master-pass') unlockSystem();
if (active.id === 'new-db-name') createDb();
if (active.closest('#card-brand')) saveBranding();
if (active.id === 'chat-in') sendHealerMsg();
if (active.id === 'crawl-url') inspectSite();
}
});
function expandDb(name) { document.getElementById('db-' + name).classList.toggle('expanded'); }
function clearBrandingFields() {
['b-bot-name','b-biz-name','b-header-title','b-header-subtitle','b-welcome',
'b-placeholder','b-page-title','b-logo-emoji','b-primary','b-secondary',
'b-bot-bubble','b-user-bubble'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
['ops-email','ops-whatsapp'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
}
function updateHealthStatus() {
const el = document.getElementById('global-status');
if (!el) return;
el.innerHTML = '<span style="opacity:0.5">Checking...</span>';
fetch(`${API}/health`).then(r => r.json()).then(h => {
const providers = (h.providers || []).map(p => escapeHtml(p)).join(', ');
const keyTxt = h.any_key_ready
? `βœ… ${Number(h.active_keys || 0)} keys (${providers})`
: '❌ No keys loaded';
const sessionDb = localStorage.getItem('adminDb') || '';
const dbTxt = sessionDb ? `βœ… Managing: ${escapeHtml(sessionDb)}` : (h.status === 'ready' ? `βœ… Server ready` : `⚠️ ${escapeHtml(h.status)}`);
el.innerHTML = `<span style="margin-right:16px">${dbTxt}</span><span>${keyTxt}</span>`;
}).catch(() => { el.innerHTML = '⚠️ Server unreachable'; });
}
async function setActiveDb(name) {
const pass = document.getElementById('master-pass').value;
const f = new FormData(); f.append("password", pass); f.append("name", name);
showStatus('db', `Switching to ${name}...`, 'loading');
const res = await authFetch(`${API}/admin/databases/set-active`, { method:"POST", headers: authHeaders(), body:f });
const d = await res.json();
showStatus('db', d.success ? `βœ… Switched to ${name}. Backend loading (~30s) β€” refresh chat after 30s.` : (d.message || d.detail), d.success ? 'success' : 'error');
if (!d.success) return;
if (d.success) {
// Owner's admin view follows the active DB: repoint X-Admin-DB so every
// panel (branding, contacts/ops, widget key, analytics, knowledge) reloads
// from the newly-active DB. Clients can't reach this path.
localStorage.setItem('adminDb', name);
// Drop the prior DB's widget snippet so Preview opens the new active bot.
window._widgetKeyReady = false;
const _es = document.getElementById('embed-snippet'); if (_es) _es.value = '';
const _km = document.getElementById('widget-key-meta'); if (_km) _km.textContent = '';
await refreshCsrf();
}
clearBrandingFields();
// Reload branding + ops from the now-active DB (authHeaders carries new X-Admin-DB)
const br = await fetch(`${API}/admin/branding`, { headers: authHeaders() });
if (br.ok) { const bd = await br.json(); loadServerData(bd); loadOpsData(bd.config_full || bd); }
loadEmbedCode();
loadCharts();
loadAnalytics();
loadKnowledgeGaps();
loadDatabases();
updateHealthStatus();
}
async function syncFromGithub() {
const pass = sessionStorage.getItem('adminPass') || '';
const db = localStorage.getItem('adminDb') || '';
const csrf = sessionStorage.getItem('csrfToken') || '';
showStatus('db', '☁️ Syncing from GitHub β€” please wait 30-60 seconds...', 'loading');
try {
const r = await authFetch(`${API}/admin/sync-github`, {
method: 'POST',
headers: {...authHeaders()}
});
const data = await r.json();
const msg = data.message || data.detail || (r.ok ? 'Sync started β€” refresh list in 30s' : 'Unknown error');
console.log('[SYNC]', r.status, JSON.stringify(data));
showStatus('db', (r.ok ? '☁️ Sync started β€” refreshing in 60s...' : '❌ ' + msg), r.ok ? 'loading' : 'error');
if (r.ok) {
setTimeout(() => { loadDatabases(); updateHealthStatus(); showStatus('db', 'βœ… Sync complete', 'success'); }, 60000);
}
} catch(e) {
console.log('[SYNC ERROR]', e);
showStatus('db', 'Sync request failed: ' + e.message, 'error');
}
}
async function createDb() {
const pass = document.getElementById('master-pass').value;
const name = document.getElementById('new-db-name').value.trim();
const dbPass = document.getElementById('new-db-pass').value.trim();
if (!name) return showStatus('db', 'Name required.', 'error');
const f = new FormData(); f.append("password", pass); f.append("name", name); f.append("db_password", dbPass);
showStatus('db', `Initializing ${name}...`, 'loading');
const res = await authFetch(`${API}/admin/create-db`, { method:"POST", headers: authHeaders(), body:f });
const d = await res.json();
if (d.success) { document.getElementById('new-db-name').value = ''; document.getElementById('new-db-pass').value = ''; }
showStatus('db', d.message || d.detail, d.success ? 'success' : 'error');
loadDatabases();
}
async function deleteDb(name) {
document.querySelectorAll('[id^="db-menu-"]').forEach(m => m.style.display = 'none');
if (!await showConfirm('Delete Database', `Permanently delete "${name}" and all its data? This cannot be undone.`, 'Delete', true)) return;
const pass = document.getElementById('master-pass').value;
const f = new FormData(); f.append("password", pass); f.append("name", name);
showStatus('db', `Purging ${name}...`, 'loading');
const res = await authFetch(`${API}/admin/delete-db`, { method:"POST", headers: authHeaders(), body:f });
const d = await res.json();
showStatus('db', d.message || d.detail, d.success ? 'success' : 'error');
loadDatabases();
}
function toggleDbMenu(name, e) {
e.stopPropagation();
const menu = document.getElementById(`db-menu-${name}`);
const isOpen = menu.style.display !== 'none';
document.querySelectorAll('[id^="db-menu-"]').forEach(m => m.style.display = 'none');
if (!isOpen) {
const btn = e.currentTarget;
const rect = btn.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.right = (window.innerWidth - rect.right) + 'px';
menu.style.left = 'auto';
menu.style.display = 'block';
const menuH = menu.offsetHeight || 120;
if (rect.bottom + menuH > window.innerHeight) {
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
} else {
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.bottom = 'auto';
}
}
}
window.addEventListener('scroll', () => document.querySelectorAll('[id^="db-menu-"]').forEach(m => m.style.display = 'none'), true);
document.addEventListener('click', () => document.querySelectorAll('[id^="db-menu-"]').forEach(m => m.style.display = 'none'));
async function renameDb(oldName) {
document.querySelectorAll('[id^="db-menu-"]').forEach(m => m.style.display = 'none');
const newName = prompt(`New name for "${oldName}":`, oldName);
if (!newName || newName.trim() === oldName) return;
const pass = document.getElementById('master-pass').value;
const f = new FormData(); f.append("password", pass); f.append("old_name", oldName); f.append("new_name", newName.trim());
const res = await authFetch(`${API}/admin/rename-db`, { method:"POST", headers: authHeaders(), body:f });
const d = await res.json();
alert(d.message || d.detail || (res.ok ? 'Renamed.' : 'Failed.'));
loadDatabases();
}
async function changeDbPass(dbName) {
document.querySelectorAll('[id^="db-menu-"]').forEach(m => m.style.display = 'none');
const newPass = prompt(`New admin password for "${dbName}":`);
if (!newPass || !newPass.trim()) return;
const pass = document.getElementById('master-pass').value;
const res = await authFetch(`${API}/admin/ops`, {
method: "POST",
headers: { ...authHeaders(), 'X-Admin-DB': dbName, 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pass, admin_password: newPass.trim() })
});
const d = await res.json();
alert(d.message || d.detail || (res.ok ? `Password updated for ${dbName}.` : 'Update failed.'));
}
async function clearDbData(name) {
if (!await showConfirm('Clear All Data', `Delete all knowledge chunks from "${name}"? Config and crawl settings are kept. This cannot be undone.`, 'Clear Data', true)) return;
const pass = document.getElementById('master-pass').value;
const f = new FormData(); f.append("password", pass); f.append("name", name);
showStatus('db', `Clearing ${name}...`, 'loading');
const res = await authFetch(`${API}/admin/databases/clear-data`, { method:"POST", headers: authHeaders(), body:f });
const d = await res.json();
showStatus('db', d.message || d.detail, d.success ? 'success' : 'error');
loadDatabases();
}
async function loadBranding() {
const pass = document.getElementById('master-pass').value;
showStatus('brand', 'Fetching current branding...', 'loading');
try {
const res = await fetch(`${API}/admin/branding`, { headers: authHeaders() });
const d = await res.json();
loadServerData(d);
showStatus('brand', 'Current branding loaded.', 'success');
} catch (e) {
showStatus('brand', 'Failed to load branding.', 'error');
}
}
async function saveOps() {
const pass = document.getElementById('master-pass').value;
const payload = {
password: pass,
contact_email: document.getElementById('ops-email').value,
whatsapp_number: document.getElementById('ops-whatsapp').value,
smtp_password: document.getElementById('ops-smtp-password').value,
always_open: true
};
showStatus('ops', 'Saving operations...', 'loading');
const res = await authFetch(`${API}/admin/ops`, {
method: "POST", headers: {...authHeaders(), "Content-Type": "application/json"},
body:JSON.stringify(payload)
});
const d = await res.json();
showStatus('ops', d.message || d.error, d.success ? 'success' : 'error');
}
function loadOpsData(cfg) {
document.getElementById('ops-email').value = cfg.contact_email || "";
document.getElementById('ops-whatsapp').value = cfg.whatsapp_number || "";
document.getElementById('ops-smtp-password').value = cfg.smtp_password || "";
}
async function loadOps() {
const pass = document.getElementById('master-pass').value;
showStatus('ops', 'Fetching settings...', 'loading');
try {
const res = await fetch(`${API}/admin/branding`, { headers: authHeaders() });
const d = await res.json();
loadOpsData(d.config_full || d);
showStatus('ops', 'Operations loaded.', 'success');
} catch (e) {
showStatus('ops', 'Failed to load operations.', 'error');
}
}
async function saveBranding() {
const pass = document.getElementById('master-pass').value;
const payload = {
password: pass,
bot_name: document.getElementById("b-bot-name").value,
business_name: document.getElementById("b-biz-name").value,
secondary_prompt: document.getElementById("b-secondary-prompt").value,
branding: {
welcome_message: document.getElementById("b-welcome").value,
primary_color: document.getElementById("b-primary-hex").value,
bot_bubble_color: document.getElementById("b-bot-bubble-hex").value,
header_title: document.getElementById("b-header-title").value,
header_subtitle: document.getElementById("b-header-subtitle").value,
logo_emoji: document.getElementById("b-logo-emoji").value,
logo_url: document.getElementById("b-logo-url").value,
input_placeholder: document.getElementById("b-placeholder").value,
page_title: document.getElementById("b-page-title").value,
font_style: document.getElementById("b-font-style").value,
secondary_color: document.getElementById("b-secondary-hex").value,
user_bubble_color: document.getElementById("b-user-bubble-hex").value
}
};
showStatus('brand', 'Pushing Changes...', 'loading');
try {
const res = await authFetch(`${API}/admin/branding`, { method: "POST", headers: {...authHeaders(), "Content-Type": "application/json"}, body:JSON.stringify(payload) });
const d = await res.json();
showStatus('brand', d.message || d.detail || d.error || 'Unknown error', d.success ? 'success' : 'error');
} catch(e) {
showStatus('brand', 'Network error β€” server may be restarting. Try again in 30 seconds.', 'error');
}
}
function updatePreview() {
const primary = document.getElementById("b-primary-hex").value;
const secondary = document.getElementById("b-secondary-hex").value;
const botBubble = document.getElementById("b-bot-bubble-hex").value;
const userBubble = document.getElementById("b-user-bubble-hex").value;
const emoji = document.getElementById("b-logo-emoji").value;
const title = document.getElementById("b-header-title").value;
const subtitle = document.getElementById("b-header-subtitle").value;
const placeholder = document.getElementById("b-placeholder").value;
const gradient = `linear-gradient(135deg, ${primary}, ${secondary})`;
document.getElementById("prev-header").style.background = gradient;
const sendBtn = document.getElementById("prev-send-btn");
if (sendBtn) sendBtn.style.background = gradient;
document.getElementById("prev-bot-bubble").style.background = botBubble;
document.getElementById("prev-user-bubble").style.background = userBubble || gradient;
document.getElementById("prev-logo").textContent = emoji || "πŸ€–";
document.getElementById("prev-title").textContent = title || "Support";
document.getElementById("prev-subtitle").textContent = subtitle || "Online";
document.getElementById("prev-input").textContent = placeholder || "Type here...";
}
document.addEventListener("input", (e) => { if (e.target.id && e.target.type !== "password") updatePreview(); });
// --- CUSTOMER LEADS ---
async function loadLeads() {
showStatus('leads', 'Loading...', 'loading');
const d = await fetch(`/admin/leads`, { headers: authHeaders() }).then(r=>r.json()).catch(()=>null);
const list = document.getElementById('leads-list');
if (!d || !Array.isArray(d.leads)) { showStatus('leads', 'Could not load leads.', 'error'); return; }
showStatus('leads', `${d.count} lead${d.count===1?'':'s'} total (showing latest ${d.leads.length}).`, 'success');
if (d.leads.length === 0) { list.innerHTML = '<p style="color:var(--text-muted);font-size:13px;">No leads yet.</p>'; return; }
list.innerHTML = d.leads.map(l => `
<div style="background:#f8fafc;border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px;font-size:13px;">
<div style="display:flex;justify-content:space-between;gap:10px;">
<strong>${escapeHtml(l.name || '(no name)')}</strong>
<span style="color:var(--text-muted);font-size:11px;">${escapeHtml((l.timestamp||'').slice(0,16).replace('T',' '))}</span>
</div>
<div style="margin-top:4px;">${escapeHtml(l.email || '')}${l.whatsapp ? ' Β· ' + escapeHtml(l.whatsapp) : ''}</div>
${l.message ? `<div style="margin-top:6px;color:var(--text-muted);">${escapeHtml(l.message)}</div>` : ''}
</div>`).join('');
}
// --- FAQ MANAGER ---
async function loadFaqs() {
const pass = document.getElementById('master-pass').value;
if (!pass) return;
const d = await fetch(`/admin/faqs`, { headers: authHeaders() }).then(r=>r.json()).catch(()=>({faqs:[]}));
const list = document.getElementById('faq-list');
if (!d.faqs || d.faqs.length === 0) { list.innerHTML = '<p style="color:var(--text-muted);font-size:13px;">No FAQs yet.</p>'; return; }
window._faqIds = d.faqs.map(f => String(f.id || ''));
list.innerHTML = d.faqs.map((f, idx) => `
<div style="background:#f8fafc;border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px;">
<div style="font-weight:700;margin-bottom:4px;">Q: ${escapeHtml(f.question)}</div>
<div style="color:var(--text-muted);font-size:13px;margin-bottom:8px;">A: ${escapeHtml(f.answer)}</div>
<button onclick="deleteFaq((window._faqIds || [])[${idx}] || '')" style="background:#ef4444;padding:4px 12px;font-size:12px;">πŸ—‘ Delete</button>
</div>`).join('');
}
async function addFaq() {
const pass = document.getElementById('master-pass').value;
const q = document.getElementById('faq-q').value.trim();
const a = document.getElementById('faq-a').value.trim();
if (!q || !a) return showStatus('faq', 'Both question and answer are required.', 'error');
showStatus('faq', 'Adding FAQ...', 'loading');
const d = await authFetch('/admin/faqs', { method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({password: pass, question: q, answer: a}) }).then(r=>r.json());
if (d.success) {
document.getElementById('faq-q').value = '';
document.getElementById('faq-a').value = '';
showStatus('faq', 'FAQ added and embedded into DB.', 'success');
loadFaqs();
} else showStatus('faq', d.detail || 'Failed.', 'error');
}
async function deleteFaq(id) {
const pass = document.getElementById('master-pass').value;
await authFetch(`/admin/faqs/${id}`, {headers: authHeaders(), method:'DELETE'});
loadFaqs();
}
// Auto-load FAQs when section opens
document.getElementById('card-faq').querySelector('.card-header').addEventListener('click', () => {
setTimeout(loadFaqs, 100);
});
// --- BEHAVIORAL SUITE LOGIC ---
async function runAudit(type = 'all') {
const pass = document.getElementById('master-pass').value;
const body = document.getElementById('audit-body');
const statusEl = document.getElementById('tests-status');
// UI Feedback
showStatus('tests', `Conducting ${type} audit...`, 'loading');
if (type === 'all') body.innerHTML = ''; // Clear for full audit
const endpoint = type === 'all' ? '/admin/test-detailed' : `/admin/test/${type}`;
try {
const res = await fetch(`${API}${endpoint}`, { headers: authHeaders() });
const data = await res.json();
const newRows = (data.results || []).map(r => {
const fixId = `fix-${Math.random().toString(36).slice(2)}`;
const safeStatus = String(r.status || '').toLowerCase().replace(/[^a-z-]/g, '');
const fixHtml = r.fix ? `
<div style="margin-top:5px;">
<span onclick="document.getElementById('${fixId}').style.display=document.getElementById('${fixId}').style.display==='none'?'block':'none'"
style="cursor:pointer;font-size:10px;color:#d97706;font-weight:600;user-select:none;">πŸ’‘ What's wrong β–Ύ</span>
<div id="${fixId}" style="display:none;margin-top:4px;font-size:10px;color:#92400e;background:#fffbeb;border:1px solid #fcd34d;border-radius:6px;padding:7px 10px;line-height:1.5;">${escapeHtml(r.fix)}</div>
</div>` : '';
const actionsHtml = (r.actions || []).map(a => {
const onclick = a.rerun
? `runAudit('${a.rerun}')`
: `document.getElementById('${a.nav}')?.scrollIntoView({behavior:'smooth',block:'start'})`;
const color = a.rerun ? '#6366f1' : '#0ea5e9';
return `<button onclick="${onclick}" style="margin-top:5px;margin-right:4px;padding:3px 10px;font-size:10px;font-weight:600;border:1.5px solid ${color};border-radius:6px;background:#fff;color:${color};cursor:pointer;">${escapeHtml(a.label)}</button>`;
}).join('');
return `
<tr class="audit-row-new">
<td style="font-weight:700;">${escapeHtml(r.name)}</td>
<td><span class="badge badge-${safeStatus}">${escapeHtml(r.status)}</span></td>
<td style="font-size:11px; line-height:1.4; color:var(--text-main);">${escapeHtml(r.desc)}${fixHtml}${actionsHtml}</td>
</tr>`;
}).join('');
if (type === 'all') {
body.innerHTML = newRows;
} else {
// Append or replace specific row
body.innerHTML += newRows;
}
document.getElementById('audit-list').style.display = 'block';
const hasFail = (data.results || []).some(r => r.status === 'FAIL');
showStatus('tests', hasFail ? 'Audit complete. Issues found.' : 'Audit complete. Systems operational.', hasFail ? 'error' : 'success');
} catch (e) {
showStatus('tests', 'Connection lost during audit.', 'error');
}
}
async function loadEmbedCode() {
const pass = document.getElementById('master-pass').value;
if (!pass) return showStatus('embed', 'Enter admin password first.', 'error');
const el = document.getElementById('embed-snippet');
const meta = document.getElementById('widget-key-meta');
showStatus('embed', 'Generating stable installation snippet...', 'loading');
try {
const res = await fetch(`${API}/admin/embed-code`, { headers: authHeaders() });
const data = await res.json();
if (!res.ok) return showStatus('embed', data.detail || 'Failed to generate snippet.', 'error');
if (el) el.value = data.snippet || '';
if (meta) meta.textContent = 'Stable installation: keys renew automatically; no website code update is required.';
window._widgetKeyReady = true;
showStatus('embed', 'Universal snippet ready.', 'success');
} catch (e) {
showStatus('embed', 'Failed to generate snippet.', 'error');
}
}
async function createWidgetKey() {
return loadEmbedCode();
}
async function previewBot() {
// Always preview the DB you are logged into. Mint/reuse a key for the
// current X-Admin-DB (authHeaders) instead of a stale snippet from a
// previously logged-in tenant β€” so switching DBs switches the bot.
const db = localStorage.getItem('adminDb') || '';
if (!db) return showStatus('embed', 'Log in first.', 'error');
showStatus('embed', `Opening ${db} bot…`, 'loading');
try {
window.open(`${API}/w/${encodeURIComponent(db)}`, '_blank');
showStatus('embed', `Opened ${db} bot.`, 'success');
} catch (e) {
showStatus('embed', 'Failed to open bot.', 'error');
}
}
function copyEmbed() {
const el = document.getElementById('embed-snippet');
if (!el || !el.value) return showStatus('embed', 'Load snippet first.', 'error');
navigator.clipboard.writeText(el.value)
.then(() => showStatus('embed', 'Copied to clipboard!', 'success'))
.catch(() => { el.select(); document.execCommand('copy'); showStatus('embed', 'Copied!', 'success'); });
}
function getIngestTarget() {
return document.getElementById('ingest-target-db').value || '';
}
function detectPasteFormat() {
const text = (document.getElementById('content').value || '').trim();
const badge = document.getElementById('fmt-badge');
const hint = document.getElementById('fmt-hint');
const override = document.getElementById('fmt-override') && document.getElementById('fmt-override').value;
if (override && override !== '') { badge.textContent = override.toUpperCase(); badge.style.display = ''; hint.textContent = '(forced)'; return; }
if (!text) { badge.style.display = 'none'; hint.textContent = ''; return; }
let fmt = 'txt';
const s = text.slice(0, 200);
if (/^https?:\/\//i.test(s.split('\n')[0].trim())) fmt = 'url';
else if (s.startsWith('{') || s.startsWith('[')) fmt = 'json';
else if (s.trimStart().startsWith('<?xml') || s.trimStart().startsWith('<')) fmt = 'xml';
else if (/^[\w ]+,[\w ]+/.test(s) && s.includes('\n')) fmt = 'csv';
else if (/^[a-z_]+:\s/m.test(s) && !s.startsWith('#')) fmt = 'yaml';
else if (s.startsWith('#') || /^#{1,6} /m.test(s)) fmt = 'md';
const colors = {url:'#dcfce7',json:'#dbeafe',xml:'#fce7f3',csv:'#d1fae5',yaml:'#fef9c3',md:'#ede9fe',txt:'#f1f5f9'};
const textColors = {url:'#15803d',json:'#1d4ed8',xml:'#be185d',csv:'#065f46',yaml:'#78350f',md:'#6d28d9',txt:'#475569'};
badge.textContent = fmt.toUpperCase();
badge.style.background = colors[fmt] || '#e2e8f0';
badge.style.color = textColors[fmt] || '#475569';
badge.style.display = '';
hint.textContent = fmt === 'url' ? 'will fetch & ingest' : 'auto-detected';
}
async function updateText() {
const pass = document.getElementById('master-pass').value;
const text = document.getElementById('content').value.trim();
if (!text) return showStatus('ingest', 'Nothing entered.', 'error');
const fmtEl = document.getElementById('fmt-override');
const fmt = fmtEl ? fmtEl.value : '';
const isUrl = /^https?:\/\//i.test(text.split('\n')[0].trim()) && !fmt;
showStatus('ingest', isUrl ? 'Fetching URL...' : 'Ingesting...', 'loading');
try {
let res, d;
if (isUrl) {
res = await authFetch(`${API}/admin/ingest/fetch-url`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({ password: pass, url: text.split('\n')[0].trim(), target_db: getIngestTarget() })
});
d = await res.json();
if (d.success) {
document.getElementById('content').value = '';
document.getElementById('fmt-badge').style.display = 'none';
document.getElementById('fmt-hint').textContent = '';
showStatus('ingest', d.message + ` (${d.chunks} items)`, 'success');
} else showStatus('ingest', d.detail || 'Fetch failed.', 'error');
} else {
res = await authFetch(`${API}/admin/ingest/smart-text`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({ password: pass, content: text, format: fmt, target_db: getIngestTarget() })
});
d = await res.json();
if (d.success) {
document.getElementById('content').value = '';
document.getElementById('fmt-badge').style.display = 'none';
document.getElementById('fmt-hint').textContent = '';
showStatus('ingest', `${d.message} [${d.format_detected?.toUpperCase()}, ${d.chunks} chunks]`, 'success');
} else showStatus('ingest', d.detail || 'Ingestion failed.', 'error');
}
} catch (e) { showStatus('ingest', 'Connection error.', 'error'); }
}
async function uploadFile() {
const pass = document.getElementById('master-pass').value;
const fileInput = document.getElementById('file-input');
if (!fileInput.files.length) return showStatus('ingest', 'No file selected.', 'error');
showStatus('ingest', 'Uploading...', 'loading');
try {
const f = new FormData();
f.append('password', pass);
f.append('target_db', getIngestTarget());
for (const file of fileInput.files) f.append('files', file);
const res = await authFetch(`${API}/admin/ingest/files`, { method: 'POST', headers: authHeaders(), body: f });
const d = await res.json();
if (d.success) { fileInput.value = ''; showStatus('ingest', d.message || 'Uploaded.', 'success'); }
else showStatus('ingest', d.detail || 'Upload failed.', 'error');
} catch (e) { showStatus('ingest', 'Connection error.', 'error'); }
}
async function fetchFromApi() {
const pass = document.getElementById('master-pass').value;
const url = document.getElementById('api-url').value.trim();
const jsonPath = document.getElementById('api-json-path').value.trim();
const statusEl = document.getElementById('api-fetch-status');
if (!url) { statusEl.textContent = 'Enter an API URL first.'; return; }
statusEl.textContent = 'Fetching...';
try {
// Fetch the external API via a proxy on our server
const res = await authFetch(`${API}/admin/ingest/fetch-url`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({ password: pass, url, json_path: jsonPath, target_db: getIngestTarget() })
});
const d = await res.json();
if (d.success) statusEl.innerHTML = `<span style="color:#16a34a;">βœ“ ${escapeHtml(d.message)} (${Number(d.chunks || 0)} chunks from ${Number(d.items || 0)} items)</span>`;
else statusEl.innerHTML = `<span style="color:#dc2626;">βœ— ${escapeHtml(d.detail || 'Failed')}</span>`;
} catch(e) { statusEl.innerHTML = `<span style="color:#dc2626;">βœ— Connection error</span>`; }
}
async function loadApiSources() {
const pass = document.getElementById('master-pass').value;
const db = getIngestTarget();
try {
const res = await fetch(`${API}/admin/api-sources?db_name=${encodeURIComponent(db)}`, { headers: authHeaders() });
const d = await res.json();
const sources = d.api_sources || [];
const el = document.getElementById('api-sources-list');
if (!sources.length) { el.innerHTML = '<div style="font-size:12px;color:var(--text-muted);">No API sources configured for this DB.</div>'; return; }
window._apiSourceNames = sources.map(s => String(s.name || ''));
el.innerHTML = sources.map((s, idx) => `
<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;font-size:12px;">
<div style="flex:1;">
<span style="font-weight:700;">${escapeHtml(s.name)}</span>
<span style="color:var(--text-muted);margin-left:8px;">${escapeHtml(String(s.url || '').slice(0,50))}${String(s.url || '').length>50?'...':''}</span>
<span style="margin-left:8px;color:#7c3aed;">every ${Number(s.interval_hours || 0)}h</span>
<span style="margin-left:8px;color:var(--text-muted);">last: ${s.last_fetch ? escapeHtml(new Date(s.last_fetch).toLocaleString()) : 'never'}</span>
${(s.keywords||[]).length ? `<span style="margin-left:8px;color:#0369a1;font-size:11px;">keys: ${escapeHtml((s.keywords || []).join(', '))}</span>` : '<span style="margin-left:8px;color:#9ca3af;font-size:11px;">always fires</span>'}
</div>
<button onclick="deleteApiSource((window._apiSourceNames || [])[${idx}] || '')" style="width:auto;margin:0;padding:4px 10px;font-size:11px;background:#fee2e2;color:#dc2626;border:none;border-radius:6px;cursor:pointer;font-weight:700;">&times;</button>
</div>`).join('');
} catch(e) { document.getElementById('api-sources-list').innerHTML = '<div style="color:#dc2626;font-size:12px;">Failed to load</div>'; }
}
async function addApiSource() {
const pass = document.getElementById('master-pass').value;
const statusEl = document.getElementById('api-src-status');
statusEl.textContent = 'Saving...'; statusEl.style.color = 'var(--text-muted)';
try {
const res = await authFetch(`${API}/admin/api-sources`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({
password: pass,
db_name: getIngestTarget(),
name: document.getElementById('api-src-name').value.trim(),
url: document.getElementById('api-src-url').value.trim(),
api_key: document.getElementById('api-src-key').value.trim(),
json_path: document.getElementById('api-src-path').value.trim(),
interval_hours: parseFloat(document.getElementById('api-src-interval').value) || 24,
keywords: document.getElementById('api-src-keywords').value.trim(),
})
});
const d = await res.json();
if (d.success) {
statusEl.textContent = 'βœ“ ' + d.message; statusEl.style.color = '#16a34a';
['api-src-name','api-src-url','api-src-key','api-src-path'].forEach(id => document.getElementById(id).value = '');
loadApiSources();
} else { statusEl.textContent = d.detail || 'Failed'; statusEl.style.color = '#dc2626'; }
} catch(e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#dc2626'; }
}
async function deleteApiSource(name) {
const pass = document.getElementById('master-pass').value;
await authFetch(`${API}/admin/api-sources/delete`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({password:pass, db_name:getIngestTarget(), name})
});
loadApiSources();
}
async function reindexDb() {
const pass = document.getElementById('master-pass').value;
// Step 1: get chunk count from health for time estimate
let chunks = 0;
try { const h = await fetch(`${API}/health`); const hd = await h.json(); chunks = hd.docs_indexed || 0; } catch(_) {}
const estMins = chunks > 0 ? Math.ceil(chunks / 1500) : 5;
showStatus('ingest', `⏳ Reindexing ${chunks.toLocaleString()} chunks with BGE… estimated ${estMins}–${estMins + 2} minutes. Keep this tab open.`, 'loading', true);
// Elapsed timer
const statusEl = document.getElementById('ingest-status');
const start = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - start) / 1000);
const m = Math.floor(elapsed / 60), s = elapsed % 60;
statusEl.textContent = `⏳ Reindexing ${chunks.toLocaleString()} chunks… ${m}m ${s}s elapsed (est. ${estMins}–${estMins + 2} min total). Keep this tab open.`;
}, 1000);
try {
const res = await authFetch(`${API}/admin/reindex`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({ password: pass, target_db: getIngestTarget() })
});
clearInterval(timer);
const d = await res.json();
const elapsed = Math.floor((Date.now() - start) / 1000);
const m = Math.floor(elapsed / 60), s = elapsed % 60;
if (d.success) {
showStatus('ingest', `βœ… Done in ${m}m ${s}s β€” ${d.message || 'Reindex complete. Startup is now ~30s.'}`, 'success', true);
} else {
showStatus('ingest', `❌ Failed after ${m}m ${s}s β€” ${d.detail || 'Reindex error.'}`, 'error', true);
}
} catch (e) {
clearInterval(timer);
showStatus('ingest', '❌ Connection lost during reindex.', 'error', true);
}
}
function inspectSite() { smartCrawlInspect(); }
function crawlSite() { fullCrawl(); }
async function smartCrawlInspect() {
const pass = document.getElementById('master-pass').value;
const url = document.getElementById('crawl-url').value.trim();
if (!url) return showStatus('crawl', 'Enter a URL first.', 'error');
const panel = document.getElementById('smart-inspect-panel');
const tableEl = document.getElementById('smart-groups-table');
panel.style.display = 'block';
tableEl.innerHTML = '<div style="font-family:monospace;font-size:11px;color:#64748b;padding:8px;">Fetching sitemap...</div>';
showStatus('crawl', 'πŸ€– Inspecting site structure...', 'loading', true);
const statusEl = document.getElementById('crawl-status');
statusEl.innerHTML = '';
statusEl.innerHTML += '<div id="inspect-logs" style="font-family:monospace;font-size:10px;background:#1e293b;color:#86efac;padding:12px;border-radius:12px;max-height:120px;overflow-y:auto;margin-top:8px;line-height:1.4;"></div>';
const logsEl = document.getElementById('inspect-logs');
const groups = [];
try {
const response = await authFetch(`${API}/admin/crawl-inspect`, {
method: 'POST', headers: {...authHeaders(), 'Content-Type': 'application/json'},
body: JSON.stringify({password: pass, url})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buf += decoder.decode(value, {stream: true});
const lines = buf.split('\n'); buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
try {
const d = JSON.parse(line.slice(5).trim());
if (d.msg && logsEl) appendSafeLog(logsEl, d.msg);
if (d.total_groups) { window._inspectTotal = d.total_groups; }
if (d.group) {
groups.push(d.group);
_renderGroupsTable(groups, tableEl);
const c = d.group.classified || groups.length;
const t = d.group.total || window._inspectTotal || '?';
showStatus('crawl', `πŸ” Classifying ${c} / ${t} groups...`, 'info', true);
}
if (d.done) break;
} catch {}
}
}
showStatus('crawl', `βœ… Inspection done β€” ${groups.length} URL groups classified. Select groups below.`, 'success', true);
} catch (e) { showStatus('crawl', '❌ Inspection failed.', 'error'); }
}
function _renderGroupsTable(groups, el) {
const sc = {5:'#16a34a',4:'#0ea5e9',3:'#f59e0b',2:'#94a3b8',1:'#ef4444'};
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:11px;">
<tr style="background:#f8fafc;font-weight:700;font-size:10px;text-transform:uppercase;color:#94a3b8;">
<th style="padding:6px;text-align:center;">Include</th>
<th style="padding:6px;text-align:center;">Crawl?</th>
<th style="padding:6px;text-align:left;">Pattern</th>
<th style="padding:6px;text-align:right;">Pages</th>
<th style="padding:6px;text-align:left;">Reason &amp; Content Preview</th>
</tr>
${groups.map(g => {
const yes = g.recommended;
const pattern = escapeHtml(g.pattern);
const sampleUrl = g.sample_url ? escapeHtml(String(g.sample_url).slice(0,60) + '...') : '';
const reason = escapeHtml(g.reason);
const badge = yes
? `<span style="background:#dcfce7;color:#16a34a;padding:2px 8px;border-radius:8px;font-weight:700;font-size:11px;">βœ… Crawl</span>`
: `<span style="background:#fee2e2;color:#dc2626;padding:2px 8px;border-radius:8px;font-weight:700;font-size:11px;">❌ Skip</span>`;
const preview = g.snippet ? `<div style="margin-top:4px;color:#94a3b8;font-size:9px;font-style:italic;line-height:1.4;">"${escapeHtml(String(g.snippet).slice(0,120))}..."</div>` : '';
return `<tr style="border-top:1px solid #e2e8f0;background:${yes?'#f0fdf4':'#fff8f8'};">
<td style="padding:6px;text-align:center;"><input type="checkbox" class="group-check" data-pattern="${pattern}" ${yes?'checked':''}></td>
<td style="padding:6px;text-align:center;">${badge}</td>
<td style="padding:6px;font-family:monospace;color:#334155;font-size:10px;">${pattern}<br><span style="color:#94a3b8;font-size:9px;">${sampleUrl}</span></td>
<td style="padding:6px;text-align:right;color:#64748b;">${Number(g.count || 0)}</td>
<td style="padding:6px;color:#64748b;font-size:10px;">${reason}${preview}</td>
</tr>`;
}).join('')}
</table>
<p style="font-size:10px;color:#94a3b8;margin-top:6px;">You can override any recommendation by checking/unchecking the box.</p>`;
}
async function startSmartCrawl() {
const checked = [...document.querySelectorAll('.group-check:checked')];
if (!checked.length) return showStatus('crawl', 'Select at least one group.', 'error');
const patterns = checked.map(c => c.dataset.pattern);
document.getElementById('smart-inspect-panel').style.display = 'none';
await fullCrawl(patterns);
}
function startDetachedCrawlReconnectLoop(db_name, pass) {
if (!db_name || !pass) return;
if (window._crawlReconnectInterval) clearInterval(window._crawlReconnectInterval);
const tryReconnect = () => {
if (document.hidden) return;
if (window._crawlPollState?.running && window._crawlPollState?.db_name === db_name) {
clearInterval(window._crawlReconnectInterval);
window._crawlReconnectInterval = null;
return;
}
resumeDetachedCrawlPolling(db_name, pass);
};
setTimeout(tryReconnect, 500);
window._crawlReconnectInterval = setInterval(tryReconnect, 5000);
}
async function resumeDetachedCrawlPolling(db_name, pass) {
if (!db_name || !pass) return;
if (window._crawlPollState?.running && window._crawlPollState?.db_name === db_name) return;
try {
const r = await fetch(`${API}/admin/crawl-status?db_name=${encodeURIComponent(db_name)}&password=${encodeURIComponent(pass)}&offset=0`);
if (!r.ok) return;
const data = await r.json();
if (!data.running || data.done) return;
const statusEl = document.getElementById('crawl-status');
if (!statusEl) return;
statusEl.innerHTML = '<div style="margin-bottom:10px;display:flex;align-items:center;gap:10px;">Reconnected to running crawl... <button onclick="cancelActiveCrawl()" style="background:#dc2626;color:#fff;border:none;padding:3px 10px;border-radius:8px;font-size:11px;cursor:pointer;">Stop</button></div><div id="crawl-logs" style="font-family:monospace; font-size:10px; background:#1e293b; color:#86efac; padding:12px; border-radius:12px; max-height:200px; overflow-y:auto; line-height:1.4;"></div>';
const logsEl = document.getElementById('crawl-logs');
for (const msg of (data.logs || [])) {
const log = document.createElement('div');
log.textContent = `> ${msg}`;
logsEl.appendChild(log);
}
logsEl.scrollTop = logsEl.scrollHeight;
window._activeCrawlDb = db_name;
window._activeCrawlPass = pass;
window._crawlPollState = {
db_name,
pass,
offset: Number(data.total || 0),
running: false,
token: Number(window._crawlPollState?.token || 0),
};
_pollCrawlStatus(db_name, pass, logsEl, statusEl);
} catch (_) {}
}
async function fullCrawl(urlPatterns = []) {
const pass = document.getElementById('master-pass').value;
const url = document.getElementById('crawl-url').value.trim();
const db_name = document.getElementById('crawl-db-name').value.trim();
const embedding_model = document.getElementById('crawl-embedding-model')?.value || 'bge';
const noLimit = document.getElementById('crawl-no-limit')?.checked;
const maxPages = noLimit ? 999999 : parseInt(document.getElementById('crawl-max-pages')?.value || '2000');
if (!url || !db_name) return showStatus('crawl', 'URL and DB name required.', 'error');
const clearFirst = document.getElementById('crawl-clear-first').checked;
if (clearFirst && !await showConfirm('Clear Before Crawl', `This will delete all existing data in "${db_name}". A backup will be saved first. Continue?`, 'Clear & Crawl', true)) return;
// Save embedding model to DB config first
await authFetch(`${API}/admin/embedding-model`, {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ password: pass, embedding_model, db_name })
});
showStatus('crawl', 'Starting crawl engine...', 'loading', true);
const statusEl = document.getElementById('crawl-status');
statusEl.innerHTML = '<div style="margin-bottom:10px;">πŸš€ Initializing...</div><div id="crawl-logs" style="font-family:monospace; font-size:10px; background:#1e293b; color:#86efac; padding:12px; border-radius:12px; max-height:200px; overflow-y:auto; line-height:1.4;"></div><div style="margin-top:6px;text-align:right;"><a href="#" id="crawl-stop-btn" onclick="event.preventDefault();cancelActiveCrawl()" style="color:#ef4444;font-size:11px;text-decoration:none;opacity:0.7;" title="Stop after current page finishes">stop crawl</a></div>';
const logsEl = document.getElementById('crawl-logs');
// Start crawl as background task β€” returns immediately, crawl runs on server
try {
const startRes = await fetch(`${API}/admin/crawl`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pass, url, db_name, max_pages: maxPages, embedding_model, clear_before_crawl: clearFirst, url_patterns: urlPatterns })
});
if (!startRes.ok) {
const err = await startRes.json();
showStatus('crawl', err.detail || 'Crawl failed to start.', 'error');
return;
}
const startData = await startRes.json();
if (startData.status === 'already_running') {
showStatus('crawl', 'βš™οΈ Crawl already running β€” polling for status...', 'loading', true);
}
} catch (e) {
showStatus('crawl', '❌ Could not start crawl.', 'error');
return;
}
// Poll /admin/crawl-status every 2s β€” works even if tab is backgrounded or refreshed
window._activeCrawlDb = db_name;
window._activeCrawlPass = pass;
window._crawlPollState = {
db_name,
pass,
offset: 0,
running: false,
token: Number(window._crawlPollState?.token || 0),
};
await _pollCrawlStatus(db_name, pass, logsEl, statusEl);
}
async function _pollCrawlStatus(db_name, pass, logsEl, statusEl) {
const state = window._crawlPollState || {};
if (state.running && state.db_name === db_name) return;
const token = Number(state.token || 0) + 1;
window._crawlPollState = {
db_name,
pass,
offset: Number(state.offset || 0),
running: true,
token,
};
while (true) {
try {
if (!window._crawlPollState || window._crawlPollState.token !== token) return;
const currentOffset = Number(window._crawlPollState.offset || 0);
const r = await fetch(`${API}/admin/crawl-status?db_name=${encodeURIComponent(db_name)}&password=${encodeURIComponent(pass)}&offset=${currentOffset}`);
if (!r.ok) { await new Promise(res => setTimeout(res, 3000)); continue; }
const data = await r.json();
for (const msg of (data.logs || [])) {
const log = document.createElement('div');
log.textContent = `> ${msg}`;
if (msg.startsWith('❌')) log.style.color = '#f87171';
logsEl.appendChild(log);
logsEl.scrollTop = logsEl.scrollHeight;
if (statusEl.firstChild) statusEl.firstChild.textContent = `βš™οΈ ${msg}`;
}
if (window._crawlPollState && window._crawlPollState.token === token) {
window._crawlPollState.offset = Number(data.total || currentOffset);
}
if (data.done) {
window._activeCrawlDb = null;
window._activeCrawlPass = null;
if (window._crawlPollState && window._crawlPollState.token === token) {
window._crawlPollState.running = false;
}
const isError = data.error || (logsEl.lastChild?.textContent || '').includes('❌');
if (isError) {
showStatus('crawl', '❌ Crawl failed β€” see logs above', 'error', true);
} else {
try {
const statsRes = await fetch(`${API}/admin/db-stats?password=${encodeURIComponent(pass)}&db_name=${encodeURIComponent(db_name)}`);
const statsJson = await statsRes.json();
const dbStat = (statsJson.stats || []).find(s => s.name === db_name) || {};
const chunkCount = dbStat.chunks ?? '?';
const sizeMB = dbStat.size_mb != null ? dbStat.size_mb.toFixed(1) : '?';
const statusDiv = document.getElementById('status-crawl');
if (statusDiv) {
statusDiv.innerHTML = `
<div style="color:#4ade80;font-weight:600;margin-bottom:8px">βœ… Crawl Complete!</div>
<div style="color:#94a3b8;margin-bottom:12px">πŸ“¦ <b style="color:#e2e8f0">${chunkCount}</b> chunks &nbsp;Β·&nbsp; πŸ’Ύ <b style="color:#e2e8f0">${sizeMB} MB</b></div>
<div id="github-sync-status" style="color:#a78bfa;font-size:13px">☁️ Syncing to GitHub automatically...</div>
`;
statusDiv.style.display = 'block';
}
} catch (_) {
showStatus('crawl', 'βœ… Crawl & Ingestion Complete!', 'success', true);
}
loadDatabases();
}
break;
}
} catch (_) {}
await new Promise(res => setTimeout(res, 2000));
}
if (window._crawlPollState && window._crawlPollState.token === token) {
window._crawlPollState.running = false;
}
}
async function syncToGithub(dbName, pass) {
const btn = event.target;
btn.disabled = true;
btn.textContent = '⏳ Saving...';
try {
const r = await fetch(`${API}/admin/sync-github`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pass, db_name: dbName })
});
const data = await r.json();
btn.textContent = 'βœ… Saved to GitHub!';
btn.style.background = '#22c55e';
} catch (e) {
btn.textContent = '❌ Sync failed';
btn.style.background = '#ef4444';
btn.disabled = false;
}
}
async function clearDraft() {
if (!await showConfirm('Clear Draft Cache', 'Clear all locally saved draft content?', 'Clear', false)) return;
Object.keys(localStorage).filter(k => k.startsWith('draft_') || k === 'chat_draft').forEach(k => localStorage.removeItem(k));
showStatus('auth', 'Local draft cache cleared.', 'success');
}
// --- RAG EVALS (OWNER) ---
async function loadEvalDbs() {
const sel = document.getElementById('eval-db');
if (!sel) return;
// Only owners should see this card, but keep function safe.
if (document.body.classList.contains('client-mode')) return;
sel.innerHTML = '';
try {
const r = await fetch(`${API}/admin/databases`, { headers: authHeaders() });
const d = await r.json();
const dbs = (d.databases || []).map(x => x.name).filter(Boolean);
if (!dbs.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No databases';
sel.appendChild(opt);
return;
}
const current = localStorage.getItem('adminDb') || dbs[0];
dbs.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
if (name === current) opt.selected = true;
sel.appendChild(opt);
});
} catch (e) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Failed to load';
sel.appendChild(opt);
}
}
function hideEvalOverlay() {
document.getElementById('eval-overlay')?.classList.remove('active');
}
function showEvalOverlay(row) {
const overlay = document.getElementById('eval-overlay');
if (!overlay) return;
document.getElementById('eval-detail-q').textContent = row.q || '';
const meta = document.getElementById('eval-detail-meta');
meta.innerHTML = '';
const pills = [
{ label: 'Difficulty', value: row.difficulty || 'β€”' },
{ label: 'Type', value: row.question_type || 'β€”' },
{ label: 'Expect', value: (row.expect && row.expect.type) ? row.expect.type : 'β€”' },
{ label: 'Retrieval', value: row.checks?.retrieval === true ? 'PASS' : row.checks?.retrieval === false ? 'FAIL' : 'β€”' },
{ label: 'Answer', value: row.checks?.answer === true ? 'PASS' : row.checks?.answer === false ? 'FAIL' : 'β€”' },
{ label: 'IDK', value: row.checks?.idk === true ? 'PASS' : row.checks?.idk === false ? 'FAIL' : 'β€”' },
];
pills.forEach(p => {
const el = document.createElement('span');
el.className = 'eval-pill';
el.textContent = `${p.label}: ${p.value}`;
meta.appendChild(el);
});
const srcEl = document.getElementById('eval-detail-src');
const expSrc = row.expect?.expected_source || '';
const retrieveSources = row.retrieve?.sources || [];
srcEl.textContent = expSrc ? `Expected source: ${expSrc}` : '';
if (retrieveSources.length) {
srcEl.textContent += `${srcEl.textContent ? ' β€’ ' : ''}Top retrieved: ${retrieveSources[0]}`;
}
const expectedEl = document.getElementById('eval-detail-expected');
const expectedRef = row.expect?.reference_text || row.expect?.reference_answer || '';
expectedEl.textContent = expectedRef || 'No explicit expected answer was stored for this row.';
const ansEl = document.getElementById('eval-detail-answer');
ansEl.textContent = row.answer?.text || 'No answer captured.';
const diagEl = document.getElementById('eval-detail-diagnosis');
const diagParts = [
`Diagnosis: ${row.diagnosis || 'pass'}`,
row.judge?.exact_failure_step ? `Failure step: ${row.judge.exact_failure_step}` : '',
row.judge?.likely_failure_source && row.judge?.likely_failure_source !== 'none' ? `Judge source: ${row.judge.likely_failure_source}` : '',
row.judge?.code_pointer ? `Code pointer: ${row.judge.code_pointer}` : '',
].filter(Boolean);
diagEl.textContent = diagParts.join('\n') || 'No diagnosis available.';
const causeEl = document.getElementById('eval-detail-cause');
const causeParts = [
row.likely_cause || '',
row.judge?.root_cause_note ? `Root cause: ${row.judge.root_cause_note}` : '',
row.judge?.fix_hint ? `Suggested fix: ${row.judge.fix_hint}` : '',
].filter(Boolean);
causeEl.textContent = causeParts.join('\n\n') || 'No likely-cause hint available.';
const metricsEl = document.getElementById('eval-detail-metrics');
metricsEl.innerHTML = '';
const metricCards = [
{ k: 'Retrieval Score', v: row.retrieve?.score != null ? `${Number(row.retrieve.score).toFixed(2)}` : 'β€”' },
{ k: 'Avg Precision', v: row.retrieve?.metrics?.average_precision != null ? `${Number(row.retrieve.metrics.average_precision).toFixed(2)}` : 'β€”' },
{ k: 'Precision@3', v: row.retrieve?.metrics?.precision_at_3 != null ? `${Number(row.retrieve.metrics.precision_at_3).toFixed(2)}` : 'β€”' },
{ k: 'Context Recall', v: row.retrieve?.metrics?.context_recall != null ? `${Number(row.retrieve.metrics.context_recall).toFixed(2)}` : 'β€”' },
{ k: 'Faithfulness', v: row.answer?.metrics?.faithfulness != null ? `${Number(row.answer.metrics.faithfulness).toFixed(2)}` : 'β€”' },
{ k: 'Answer Relevance', v: row.answer?.metrics?.answer_relevance != null ? `${Number(row.answer.metrics.answer_relevance).toFixed(2)}` : 'β€”' },
{ k: 'Judge Confidence', v: row.judge?.confidence != null ? `${Number(row.judge.confidence).toFixed(2)}` : 'β€”' },
{ k: 'Expected Type', v: row.expect?.type || 'β€”' },
];
metricCards.forEach(m => {
const card = document.createElement('div');
card.className = 'eval-mini-card';
card.innerHTML = `<div class="k">${escapeHtml(m.k)}</div><div class="v">${escapeHtml(String(m.v))}</div>`;
metricsEl.appendChild(card);
});
const pres = document.getElementById('eval-detail-present');
const miss = document.getElementById('eval-detail-missing');
pres.innerHTML = '';
miss.innerHTML = '';
(row.answer?.present_facts || []).forEach(f => {
const d = document.createElement('div');
d.className = 'eval-fact';
d.textContent = f;
pres.appendChild(d);
});
(row.answer?.missing_facts || []).forEach(f => {
const d = document.createElement('div');
d.className = 'eval-fact';
d.style.borderColor = '#fecaca';
d.style.background = '#fff1f2';
d.textContent = f;
miss.appendChild(d);
});
const srcs = document.getElementById('eval-detail-sources');
srcs.innerHTML = '';
const docs = row.retrieve?.docs || [];
if (docs && docs.length) {
docs.forEach(d => {
const wrap = document.createElement('div');
wrap.className = 'eval-fact';
const top = document.createElement('div');
top.style.fontWeight = '800';
top.textContent = (d.source || 'source').toString();
const prev = document.createElement('div');
prev.style.marginTop = '4px';
prev.style.fontSize = '11px';
prev.style.color = 'var(--text-muted)';
prev.textContent = (d.preview || '').toString().slice(0, 320);
wrap.appendChild(top);
wrap.appendChild(prev);
srcs.appendChild(wrap);
});
} else {
(row.retrieve?.sources || []).forEach(s => {
const div = document.createElement('div');
div.className = 'eval-fact';
div.textContent = s;
srcs.appendChild(div);
});
}
overlay.classList.add('active');
}
function _badge(val) {
if (val === true) return '<span class="badge badge-pass">PASS</span>';
if (val === false) return '<span class="badge badge-fail">FAIL</span>';
return '<span class="badge badge-na">β€”</span>';
}
function _renderEvalMeaning(scores, mode) {
const r = Number(scores?.retrieval || 0);
const a = Number(scores?.answer || 0);
const i = Number(scores?.idk || 0);
if (mode === 'retrieve') {
return 'Retrieve-only mode checks whether we fetch relevant sources. If this is low, focus on chunking, embeddings, filters, and ingestion quality.';
}
if (r < 6) return 'Retrieval is the bottleneck. The bot cannot answer well if it can’t fetch the right chunks. Improve chunking, source metadata, ingestion, and retrieval filters.';
if (a < 6) return 'Answer is the bottleneck. Retrieval looks OK, but the bot isn’t using the context well. Tune the prompt, reduce overly strict guardrails, or adjust formatting rules.';
if (i < 6) return 'IDK/Refusal is the bottleneck. The bot is either hallucinating or refusing incorrectly. Tune the scope/IDK rules and refusal wording.';
return 'Looks healthy. If you want a higher score, increase the number of β€œhard/very hard” questions and focus on the failures below.';
}
function _renderEvalSummary(summary) {
const wrap = document.getElementById('eval-summary');
if (!wrap) return;
const diag = summary?.diagnosis_counts || {};
const causes = summary?.likely_cause_counts || {};
const qtypes = summary?.question_type_counts || {};
const sources = summary?.failure_source_mix || {};
const steps = summary?.failure_step_mix || {};
const rootCauses = summary?.root_cause_mix || {};
const fixHints = summary?.fix_hint_mix || {};
const judgeMode = summary?.judge_mode || (summary?.judge_key_configured ? 'llm' : 'deterministic_fallback');
const judgeRows = [
`<div class="eval-fact">Mode: ${escapeHtml(judgeMode === 'llm' ? 'LLM judge key configured' : 'Deterministic fallback')}</div>`,
`<div class="eval-fact">Judge unavailable rows: ${Number(summary?.judge_unavailable_rows || 0)}</div>`,
`<div class="eval-fact">Mean confidence: ${summary?.judge_confidence_mean != null ? Number(summary.judge_confidence_mean).toFixed(2) : 'β€”'}</div>`,
].join('');
const diagRows = Object.entries(diag).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `<div class="eval-fact">${escapeHtml(k)}: ${Number(v)}</div>`).join('') || '<div class="eval-fact">No diagnostics yet.</div>';
const causeRows = Object.entries(causes).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `<div class="eval-fact">${escapeHtml(k)} (${Number(v)})</div>`).join('') || '<div class="eval-fact">No likely-cause hints yet.</div>';
const typeRows = Object.entries(qtypes).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `<div class="eval-fact">${escapeHtml(k)}: ${Number(v)}</div>`).join('') || '<div class="eval-fact">No question mix yet.</div>';
const sourceRows = Object.entries(sources).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `<div class="eval-fact">${escapeHtml(k)}: ${Number(v)}</div>`).join('') || '<div class="eval-fact">No failed source mix.</div>';
const stepRows = Object.entries(steps).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `<div class="eval-fact">${escapeHtml(k)}: ${Number(v)}</div>`).join('') || '<div class="eval-fact">No failed step mix.</div>';
const rootRows = Object.entries(rootCauses).sort((a,b)=>b[1]-a[1]).slice(0, 4).map(([k,v]) => `<div class="eval-fact">${escapeHtml(k)} (${Number(v)})</div>`).join('') || '<div class="eval-fact">No root-cause notes yet.</div>';
const fixRows = Object.entries(fixHints).sort((a,b)=>b[1]-a[1]).slice(0, 4).map(([k,v]) => `<div class="eval-fact">${escapeHtml(k)} (${Number(v)})</div>`).join('') || '<div class="eval-fact">No fix hints yet.</div>';
wrap.innerHTML = `
<div class="eval-summary-panel">
<h4>Judge Mode</h4>
${judgeRows}
</div>
<div class="eval-summary-panel">
<h4>Failure Mix</h4>
${diagRows}
</div>
<div class="eval-summary-panel">
<h4>Likely Causes</h4>
${causeRows}
</div>
<div class="eval-summary-panel">
<h4>Question Mix</h4>
${typeRows}
</div>
<div class="eval-summary-panel">
<h4>Failure Source</h4>
${sourceRows}
</div>
<div class="eval-summary-panel">
<h4>Failure Step</h4>
${stepRows}
</div>
<div class="eval-summary-panel">
<h4>Root Causes</h4>
${rootRows}
</div>
<div class="eval-summary-panel">
<h4>Fix Hints</h4>
${fixRows}
</div>
`;
wrap.style.display = 'grid';
}
function _evalJudgeKey() {
return (document.getElementById('ops-judge-api-key')?.value || '').trim();
}
function _confirmJudgeKeyForEval(mode) {
if (!['chat', 'both'].includes(mode)) return true;
if (_evalJudgeKey()) return true;
return confirm(
'Chat evals are much more reliable with a separate judge key. No judge key is configured, so this run will use deterministic fallback and be marked low-confidence. Continue?'
);
}
function refreshEvalJudgeWarning() {
const el = document.getElementById('eval-judge-warning');
if (!el) return;
const mode = document.getElementById('eval-mode')?.value || 'retrieve';
const hasJudge = Boolean(_evalJudgeKey());
if (mode === 'retrieve') {
el.style.background = '#f8fafc';
el.style.borderColor = 'var(--border)';
el.style.color = 'var(--text-muted)';
el.textContent = 'Retrieve-only evals do not need a judge key.';
} else if (hasJudge) {
el.style.background = '#ecfdf5';
el.style.borderColor = '#bbf7d0';
el.style.color = '#166534';
el.textContent = 'Chat eval judge: separate judge key configured β€” LLM grading enabled.';
} else {
el.style.background = '#fff7ed';
el.style.borderColor = '#fed7aa';
el.style.color = '#9a3412';
el.textContent = 'Chat eval judge: no judge key configured β€” deterministic-only, low-confidence scores.';
}
}
async function loadHistoryCount(db) {
if (!db) return;
try {
const r = await authFetch(`${API}/admin/evals/history?db_name=${encodeURIComponent(db)}&password=${encodeURIComponent(sessionStorage.getItem('adminPass') || '')}`);
const d = await r.json().catch(() => ({}));
const n = d.count || 0;
document.getElementById('history-count').textContent = n;
document.getElementById('btn-run-history').disabled = n === 0;
} catch(_) {}
}
document.getElementById('eval-db')?.addEventListener('change', e => loadHistoryCount(e.target.value));
document.getElementById('eval-mode')?.addEventListener('change', refreshEvalJudgeWarning);
document.getElementById('ops-judge-api-key')?.addEventListener('input', refreshEvalJudgeWarning);
refreshEvalJudgeWarning();
async function generateEvalSet(strategy) {
const db = document.getElementById('eval-db')?.value || localStorage.getItem('adminDb') || '';
const count = Number(document.getElementById('eval-count')?.value || 12);
if (!db) return showStatus('eval', 'Select a DB first.', 'error');
showStatus('eval', `Generating eval set (${strategy})...`, 'loading', true);
try {
const r = await authFetch(`${API}/admin/evals/generate`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ password: sessionStorage.getItem('adminPass') || '', db_name: db, count, strategy }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) return showStatus('eval', d.detail || 'Generate failed', 'error', true);
showStatus('eval', `βœ… Generated ${d.count || count} questions for ${db} (${d.history_count || '?'} saved total)`, 'success', true);
document.getElementById('history-count').textContent = d.history_count || '?';
} catch (e) {
showStatus('eval', 'Generate failed: ' + e.message, 'error', true);
}
}
async function runFromHistory() {
const db = document.getElementById('eval-db')?.value || localStorage.getItem('adminDb') || '';
const mode = document.getElementById('eval-mode')?.value || 'retrieve';
if (!db) return showStatus('eval', 'Select a DB first.', 'error');
if (!_confirmJudgeKeyForEval(mode)) return showStatus('eval', 'Eval cancelled. Add a separate judge key in Owner Ops for higher-confidence chat grading.', 'error', true);
const judgeKey = _evalJudgeKey();
const n = document.getElementById('history-count').textContent;
showStatus('eval', `Running eval from saved questions (${n} total, mode=${mode})...`, 'loading', true);
document.getElementById('eval-results').style.display = 'none';
document.getElementById('eval-scores').style.display = 'none';
document.getElementById('eval-summary').style.display = 'none';
try {
const r = await authFetch(`${API}/admin/evals/run`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ password: sessionStorage.getItem('adminPass') || '', db_name: db, mode, source: 'history', judge_key: judgeKey }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) return showStatus('eval', d.detail || 'Eval failed', 'error', true);
document.getElementById('score-overall').textContent = `${d.scores?.overall ?? 'β€”'}/10`;
document.getElementById('score-retrieval').textContent = `${d.scores?.retrieval ?? 'β€”'}/10`;
document.getElementById('score-answer').textContent = `${d.scores?.answer ?? 'β€”'}/10`;
document.getElementById('score-idk').textContent = `${d.scores?.idk ?? 'β€”'}/10`;
document.getElementById('eval-meaning').textContent = _renderEvalMeaning(d.scores, d.mode);
document.getElementById('eval-scores').style.display = 'block';
_renderEvalSummary(d.summary || {});
const body = document.getElementById('eval-body');
body.innerHTML = '';
window._lastEvalResults = (d.results || []);
window._lastEvalResults.forEach((row, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="font-size:11px;font-weight:800;color:var(--text-muted);text-transform:uppercase;">${escapeHtml(row.difficulty || 'β€”')}</td>
<td style="font-weight:700;">${escapeHtml(row.q || '')}</td>
<td>${_badge(row.checks?.retrieval)}</td>
<td>${_badge(row.checks?.answer)}</td>
<td>${_badge(row.checks?.idk)}</td>
<td style="font-size:11px;color:var(--text-muted);font-weight:700;">${escapeHtml(row.diagnosis || 'pass')}</td>
<td style="text-align:right;"><button class="secondary" style="width:auto;margin:0;padding:6px 10px;font-size:11px;" onclick="showEvalOverlay(window._lastEvalResults[${idx}])">View</button></td>`;
body.appendChild(tr);
});
document.getElementById('eval-results').style.display = 'block';
showStatus('eval', `βœ… Eval complete β€” ${d.results?.length || 0} questions run from history`, 'success', true);
} catch (e) {
showStatus('eval', 'Eval failed: ' + e.message, 'error', true);
}
}
async function runEvalSuite() {
const db = document.getElementById('eval-db')?.value || localStorage.getItem('adminDb') || '';
const mode = document.getElementById('eval-mode')?.value || 'retrieve';
const count = Number(document.getElementById('eval-count')?.value || 12);
if (!db) return showStatus('eval', 'Select a DB first.', 'error');
if (!_confirmJudgeKeyForEval(mode)) return showStatus('eval', 'Eval cancelled. Add a separate judge key in Owner Ops for higher-confidence chat grading.', 'error', true);
const judgeKey = _evalJudgeKey();
showStatus('eval', `Running eval suite (${mode})...`, 'loading', true);
document.getElementById('eval-results').style.display = 'none';
document.getElementById('eval-scores').style.display = 'none';
document.getElementById('eval-summary').style.display = 'none';
try {
const r = await authFetch(`${API}/admin/evals/run`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ password: sessionStorage.getItem('adminPass') || '', db_name: db, mode, count, judge_key: judgeKey }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) return showStatus('eval', d.detail || 'Eval failed', 'error', true);
document.getElementById('score-overall').textContent = `${d.scores?.overall ?? 'β€”'}/10`;
document.getElementById('score-retrieval').textContent = `${d.scores?.retrieval ?? 'β€”'}/10`;
document.getElementById('score-answer').textContent = `${d.scores?.answer ?? 'β€”'}/10`;
document.getElementById('score-idk').textContent = `${d.scores?.idk ?? 'β€”'}/10`;
document.getElementById('eval-meaning').textContent = _renderEvalMeaning(d.scores, d.mode);
document.getElementById('eval-scores').style.display = 'block';
_renderEvalSummary(d.summary || {});
const body = document.getElementById('eval-body');
body.innerHTML = '';
window._lastEvalResults = (d.results || []);
window._lastEvalResults.forEach((row, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="font-size:11px;font-weight:800;color:var(--text-muted);text-transform:uppercase;">${escapeHtml(row.difficulty || 'β€”')}</td>
<td style="font-weight:700;">${escapeHtml(row.q || '')}</td>
<td>${_badge(row.checks?.retrieval)}</td>
<td>${_badge(row.checks?.answer)}</td>
<td>${_badge(row.checks?.idk)}</td>
<td style="font-size:11px;color:var(--text-muted);font-weight:700;">${escapeHtml(row.diagnosis || 'pass')}</td>
<td style="text-align:right;">
<button class="secondary" style="width:auto;margin:0;padding:6px 10px;font-size:11px;" onclick="showEvalOverlay(window._lastEvalResults[${idx}])">View</button>
</td>
`;
body.appendChild(tr);
});
document.getElementById('eval-results').style.display = 'block';
showStatus('eval', `βœ… Done. Overall ${d.scores?.overall ?? 'β€”'}/10`, 'success', true);
} catch (e) {
showStatus('eval', 'Eval failed: ' + e.message, 'error', true);
}
}
function endHealerSession() {
document.getElementById('audit-list').style.display = 'none';
showStatus('tests', 'Session closed & secured.', 'success');
}
</script>
</body>
</html>