Spaces:
Sleeping
Sleeping
| <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 ; } | |
| .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 ; } | |
| #client-preview-section { display: none; } | |
| body.client-mode #client-preview-section { display: block ; } | |
| </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 & 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 & 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&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 </body><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 => ({ | |
| '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' | |
| }[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;">×</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 & 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 Β· πΎ <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> | |