cx-bot / index.html
rnyx's picture
Initial commit: domain-aware multi-agent CX bot
dcfd487
Raw
History Blame Contribute Delete
20.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CX Bot — Dispatch Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#14191d;
--surface:#1c2227;
--surface-2:#202830;
--border:#2a3138;
--text:#e8e6e1;
--text-dim:#9ba3ab;
--text-faint:#5e666e;
--accent:#e8a33d;
--good:#6fbe8f;
--warn:#e8a33d;
--bad:#e2685a;
--billing:#5fa8d3;
--returns:#7cb88f;
--escalation:#e2685a;
--radius:9px;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
background:var(--bg);
color:var(--text);
font-family:'Inter',system-ui,sans-serif;
min-height:100vh;
line-height:1.5;
}
.mono{font-family:'JetBrains Mono',monospace;}
.display{font-family:'Space Grotesk',sans-serif;}
/* ---- status bar ---- */
.statusbar{
display:flex;
align-items:center;
gap:24px;
padding:14px 22px;
border-bottom:1px solid var(--border);
background:var(--surface);
flex-wrap:wrap;
}
.brand{
font-family:'Space Grotesk',sans-serif;
font-weight:700;
font-size:18px;
letter-spacing:0.02em;
display:flex;
align-items:center;
gap:9px;
}
.brand .sub{
font-family:'JetBrains Mono',monospace;
font-weight:400;
font-size:11px;
color:var(--text-faint);
letter-spacing:0.04em;
text-transform:uppercase;
}
.dot{
width:8px;height:8px;border-radius:50%;
background:var(--text-faint);
flex-shrink:0;
}
.dot.live{background:var(--good); box-shadow:0 0 0 0 rgba(111,190,143,0.6); animation:pulse 2s infinite;}
.dot.down{background:var(--bad);}
@keyframes pulse{
0%{box-shadow:0 0 0 0 rgba(111,190,143,0.5);}
70%{box-shadow:0 0 0 7px rgba(111,190,143,0);}
100%{box-shadow:0 0 0 0 rgba(111,190,143,0);}
}
@media (prefers-reduced-motion: reduce){ .dot.live{animation:none;} }
.statusbar .right{
margin-left:auto;
display:flex;
align-items:center;
gap:10px;
flex-wrap:wrap;
}
.field-label{
font-size:10px;
text-transform:uppercase;
letter-spacing:0.08em;
color:var(--text-faint);
}
.api-input{
background:var(--surface-2);
border:1px solid var(--border);
color:var(--text);
font-family:'JetBrains Mono',monospace;
font-size:12px;
padding:6px 9px;
border-radius:6px;
width:200px;
}
.api-input:focus{outline:1px solid var(--accent); outline-offset:0;}
.btn-ghost{
background:transparent;
border:1px solid var(--border);
color:var(--text-dim);
font-family:'Inter',sans-serif;
font-size:12px;
padding:6px 12px;
border-radius:6px;
cursor:pointer;
}
.btn-ghost:hover{border-color:var(--text-dim); color:var(--text);}
.status-text{font-size:11px; color:var(--text-faint); font-family:'JetBrains Mono',monospace;}
.session-id{color:var(--text-dim);}
/* ---- layout ---- */
.console{
display:grid;
grid-template-columns:340px 1fr;
gap:0;
max-width:1180px;
margin:0 auto;
}
@media (max-width:820px){
.console{grid-template-columns:1fr;}
}
.intake{
padding:26px 22px;
border-right:1px solid var(--border);
}
@media (max-width:820px){
.intake{border-right:none; border-bottom:1px solid var(--border);}
}
.eyebrow{
font-size:11px;
text-transform:uppercase;
letter-spacing:0.1em;
color:var(--text-faint);
margin-bottom:10px;
font-weight:600;
}
textarea#queryInput{
width:100%;
min-height:120px;
background:var(--surface);
border:1px solid var(--border);
color:var(--text);
font-family:'Inter',sans-serif;
font-size:14.5px;
padding:13px;
border-radius:var(--radius);
resize:vertical;
}
textarea#queryInput:focus{outline:1px solid var(--accent); outline-offset:0;}
textarea#queryInput::placeholder{color:var(--text-faint);}
#dispatchBtn{
margin-top:12px;
width:100%;
background:var(--accent);
color:#1a1406;
border:none;
font-family:'Space Grotesk',sans-serif;
font-weight:600;
font-size:14px;
padding:11px 14px;
border-radius:var(--radius);
cursor:pointer;
transition:opacity 0.15s ease;
}
#dispatchBtn:hover{opacity:0.88;}
#dispatchBtn:disabled{opacity:0.45; cursor:not-allowed;}
.hint{
margin-top:14px;
font-size:12px;
color:var(--text-faint);
line-height:1.6;
}
.hint button{
background:none;border:none;padding:0;
color:var(--text-dim);
font-size:12px;
text-decoration:underline;
text-decoration-color:var(--border);
cursor:pointer;
display:block;
margin-top:6px;
font-family:'JetBrains Mono',monospace;
}
.hint button:hover{color:var(--accent);}
.session-row{
margin-top:24px;
padding-top:16px;
border-top:1px solid var(--border);
display:flex;
justify-content:space-between;
align-items:center;
font-size:11px;
}
.upload-panel{
margin-top:22px;
padding-top:18px;
border-top:1px solid var(--border);
}
.dropzone{
border:1px dashed var(--border);
border-radius:var(--radius);
padding:18px 12px;
text-align:center;
font-size:12px;
color:var(--text-faint);
cursor:pointer;
transition:border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.dropzone:hover{border-color:var(--text-dim); color:var(--text-dim);}
.dropzone.dragover{border-color:var(--accent); color:var(--accent); background:rgba(232,163,61,0.06);}
.upload-status{
margin-top:9px;
font-family:'JetBrains Mono',monospace;
font-size:11px;
color:var(--text-faint);
min-height:14px;
}
.upload-status.ok{color:var(--good);}
.upload-status.bad{color:var(--bad);}
/* ---- feed ---- */
.feed{
padding:26px 22px;
display:flex;
flex-direction:column;
gap:16px;
}
.empty-state{
border:1px dashed var(--border);
border-radius:var(--radius);
padding:34px 20px;
text-align:center;
color:var(--text-faint);
font-size:13.5px;
}
.progress-card{
border:1px solid var(--border);
background:var(--surface);
border-radius:var(--radius);
padding:16px 18px;
font-family:'JetBrains Mono',monospace;
font-size:12.5px;
color:var(--text-dim);
display:flex;
align-items:center;
gap:10px;
}
.spinner{
width:12px;height:12px;
border:2px solid var(--border);
border-top-color:var(--accent);
border-radius:50%;
animation:spin 0.8s linear infinite;
flex-shrink:0;
}
@keyframes spin{to{transform:rotate(360deg);}}
@media (prefers-reduced-motion: reduce){ .spinner{animation:none;} }
.ticket-card{
border:1px solid var(--border);
background:var(--surface);
border-radius:var(--radius);
padding:18px 20px;
position:relative;
overflow:hidden;
animation:rise 0.28s ease;
}
@keyframes rise{from{opacity:0; transform:translateY(6px);} to{opacity:1; transform:translateY(0);}}
@media (prefers-reduced-motion: reduce){ .ticket-card{animation:none;} }
.ticket-card.error{border-color:var(--bad);}
.ticket-top{
display:flex;
align-items:center;
gap:12px;
flex-wrap:wrap;
margin-bottom:12px;
}
.agent-tag{
font-family:'JetBrains Mono',monospace;
font-size:11px;
font-weight:600;
text-transform:uppercase;
letter-spacing:0.06em;
padding:4px 9px;
border-radius:5px;
color:#0e1316;
}
.agent-tag.billing{background:var(--billing);}
.agent-tag.returns{background:var(--returns);}
.agent-tag.escalation{background:var(--escalation);}
.gauge-wrap{
display:flex;
align-items:center;
gap:7px;
flex:1;
min-width:120px;
}
.gauge-track{
flex:1;
height:5px;
background:var(--surface-2);
border-radius:3px;
overflow:hidden;
}
.gauge-fill{height:100%; border-radius:3px;}
.gauge-label{
font-family:'JetBrains Mono',monospace;
font-size:11px;
color:var(--text-dim);
white-space:nowrap;
}
.low-conf-banner{
font-size:11.5px;
color:var(--warn);
background:rgba(232,163,61,0.1);
border:1px solid rgba(232,163,61,0.3);
border-radius:6px;
padding:6px 10px;
margin-bottom:12px;
font-family:'JetBrains Mono',monospace;
}
.query-echo{
font-size:12px;
color:var(--text-faint);
margin-bottom:10px;
}
.query-echo span{color:var(--text-dim);}
.answer{
font-size:14.5px;
color:var(--text);
white-space:pre-wrap;
margin-bottom:6px;
}
.disclaimer{
font-size:11.5px;
color:var(--text-faint);
font-style:italic;
margin-top:8px;
}
.stub-row{
position:relative;
height:1px;
border-top:1px dashed var(--border);
margin:16px -20px 14px;
}
.stub-row::before,.stub-row::after{
content:"";
position:absolute;
top:-8px;
width:16px;height:16px;
border-radius:50%;
background:var(--bg);
}
.stub-row::before{left:-8px;}
.stub-row::after{right:-8px;}
.ticket-footer{
display:flex;
flex-wrap:wrap;
gap:14px;
align-items:center;
font-family:'JetBrains Mono',monospace;
font-size:11px;
color:var(--text-faint);
}
.ticket-footer .item b{color:var(--text-dim); font-weight:500;}
.sources{
display:flex;
gap:6px;
flex-wrap:wrap;
}
.source-chip{
background:var(--surface-2);
border:1px solid var(--border);
border-radius:4px;
padding:2px 7px;
font-size:10.5px;
color:var(--text-dim);
}
.cache-flag{color:var(--good);}
</style>
</head>
<body>
<div class="statusbar">
<div class="brand">
CX BOT
<span class="dot" id="statusDot"></span>
<span class="sub">dispatch console</span>
</div>
<div class="right">
<span class="field-label">api</span>
<input class="api-input mono" id="apiBase" value="http://localhost:8000" spellcheck="false">
<button class="btn-ghost" id="pingBtn">Check</button>
<span class="status-text" id="statusText">checking…</span>
<button class="btn-ghost" id="clearBtn">Clear session</button>
</div>
</div>
<div class="console">
<div class="intake">
<div class="eyebrow">New query</div>
<textarea id="queryInput" placeholder="Type a customer query…"></textarea>
<button id="dispatchBtn">Dispatch →</button>
<div class="hint">
Try one of these:
<button data-q="Why was I charged twice on my last invoice?">Why was I charged twice on my last invoice?</button>
<button data-q="Can I return a product I bought 20 days ago?">Can I return a product I bought 20 days ago?</button>
<button data-q="I've contacted support 3 times and my issue is still not resolved. I need to speak with a manager.">I need to speak with a manager — this isn't resolved.</button>
</div>
<div class="session-row">
<span class="field-label">session</span>
<span class="mono session-id" id="sessionIdLabel"></span>
</div>
<div class="upload-panel">
<div class="eyebrow">Add a document</div>
<select id="uploadDomain" class="api-input mono" style="width:100%; margin-bottom:8px;">
<option value="billing">billing</option>
<option value="returns">returns</option>
<option value="escalation">escalation</option>
</select>
<div class="dropzone" id="dropzone">
<span id="dropzoneText">Drop a .txt or .md file here, or click to browse</span>
<input type="file" id="fileInput" accept=".txt,.md" hidden>
</div>
<div class="upload-status" id="uploadStatus"></div>
</div>
</div>
<div class="feed" id="feed">
<div class="empty-state" id="emptyState">No tickets yet. Submit a query to see it routed and answered.</div>
</div>
</div>
<script>
(function(){
function uuid(){
if(crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c){
var r = Math.random()*16|0, v = c==='x'?r:(r&0x3|0x8);
return v.toString(16);
});
}
var sessionId = uuid();
document.getElementById('sessionIdLabel').textContent = sessionId.slice(0,8);
var apiInput = document.getElementById('apiBase');
var statusDot = document.getElementById('statusDot');
var statusText = document.getElementById('statusText');
var feed = document.getElementById('feed');
var emptyState = document.getElementById('emptyState');
var queryInput = document.getElementById('queryInput');
var dispatchBtn = document.getElementById('dispatchBtn');
function apiBase(){
return apiInput.value.replace(/\/+$/,'');
}
function setStatus(ok, msg){
statusDot.className = 'dot ' + (ok===null ? '' : (ok ? 'live' : 'down'));
statusText.textContent = msg;
}
function checkHealth(){
setStatus(null, 'checking…');
fetch(apiBase() + '/health').then(function(r){
if(!r.ok) throw new Error('bad status');
return r.json();
}).then(function(d){
setStatus(true, 'connected · ' + (d.agents||[]).length + ' agents online');
}).catch(function(){
setStatus(false, 'unreachable');
});
}
document.getElementById('pingBtn').addEventListener('click', checkHealth);
checkHealth();
document.getElementById('clearBtn').addEventListener('click', function(){
fetch(apiBase() + '/session/' + sessionId, { method:'DELETE' }).catch(function(){});
sessionId = uuid();
document.getElementById('sessionIdLabel').textContent = sessionId.slice(0,8);
feed.innerHTML = '';
feed.appendChild(emptyState);
});
document.querySelectorAll('.hint button[data-q]').forEach(function(btn){
btn.addEventListener('click', function(){
queryInput.value = btn.getAttribute('data-q');
queryInput.focus();
});
});
var STAGES = ['Redacting PII…','Checking cache…','Routing query…','Retrieving context…','Generating answer…'];
function confidenceColor(c){
if(c >= 0.65) return 'var(--good)';
if(c >= 0.5) return 'var(--warn)';
return 'var(--bad)';
}
function escapeHtml(s){
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function renderTicket(query, data){
var card = document.createElement('div');
card.className = 'ticket-card';
var pct = Math.round((data.confidence||0)*100);
var color = confidenceColor(data.confidence||0);
var sourcesHtml = (data.sources||[]).map(function(s){
return '<span class="source-chip">' + escapeHtml(s.split(/[\\/]/).pop()) + '</span>';
}).join('');
card.innerHTML =
'<div class="query-echo">Query: <span>' + escapeHtml(query) + '</span></div>' +
'<div class="ticket-top">' +
'<span class="agent-tag ' + escapeHtml(data.agent||'') + '">' + escapeHtml(data.agent||'unknown') + '</span>' +
'<div class="gauge-wrap">' +
'<div class="gauge-track"><div class="gauge-fill" style="width:' + pct + '%;background:' + color + ';"></div></div>' +
'<span class="gauge-label">' + pct + '%</span>' +
'</div>' +
'</div>' +
(data.low_confidence ? '<div class="low-conf-banner">⚠ LOW CONFIDENCE — flagged for review</div>' : '') +
'<div class="answer">' + escapeHtml(data.answer||'') + '</div>' +
(data.disclaimer ? '<div class="disclaimer">' + escapeHtml(data.disclaimer) + '</div>' : '') +
'<div class="stub-row"></div>' +
'<div class="ticket-footer">' +
'<span class="item"><b>audit</b> ' + escapeHtml((data.audit_id||'').slice(0,8)) + '</span>' +
'<span class="item"><b>latency</b> ' + (data.latency_ms!=null ? data.latency_ms+'ms' : '—') + '</span>' +
'<span class="item' + (data.cache_hit ? ' cache-flag' : '') + '">' + (data.cache_hit ? '⚡ cache hit' : 'cache miss') + '</span>' +
'<span class="sources">' + sourcesHtml + '</span>' +
'</div>';
feed.insertBefore(card, feed.firstChild);
}
function renderError(query, message){
var card = document.createElement('div');
card.className = 'ticket-card error';
card.innerHTML =
'<div class="query-echo">Query: <span>' + escapeHtml(query) + '</span></div>' +
'<div class="answer">Dispatch failed — ' + escapeHtml(message) + '</div>';
feed.insertBefore(card, feed.firstChild);
}
function dispatch(){
var q = queryInput.value.trim();
if(!q) return;
if(emptyState.parentNode) emptyState.remove();
dispatchBtn.disabled = true;
var progress = document.createElement('div');
progress.className = 'progress-card';
progress.innerHTML = '<div class="spinner"></div><span class="stage-text">' + STAGES[0] + '</span>';
feed.insertBefore(progress, feed.firstChild);
var stageIdx = 0;
var stageEl = progress.querySelector('.stage-text');
var stageTimer = setInterval(function(){
stageIdx = (stageIdx + 1) % STAGES.length;
stageEl.textContent = STAGES[stageIdx];
}, 1400);
fetch(apiBase() + '/query', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({session_id: sessionId, query: q})
}).then(function(r){
if(!r.ok) return r.json().then(function(e){ throw new Error(e.detail || ('HTTP ' + r.status)); });
return r.json();
}).then(function(data){
clearInterval(stageTimer);
progress.remove();
renderTicket(q, data);
}).catch(function(err){
clearInterval(stageTimer);
progress.remove();
renderError(q, 'could not reach ' + apiBase() + '. Confirm the server is running and the URL is correct. (' + err.message + ')');
}).finally(function(){
dispatchBtn.disabled = false;
});
queryInput.value = '';
}
dispatchBtn.addEventListener('click', dispatch);
queryInput.addEventListener('keydown', function(e){
if(e.key === 'Enter' && (e.metaKey || e.ctrlKey)){
dispatch();
}
});
// ---- document upload ----
var dropzone = document.getElementById('dropzone');
var dropzoneText = document.getElementById('dropzoneText');
var fileInput = document.getElementById('fileInput');
var uploadDomain = document.getElementById('uploadDomain');
var uploadStatus = document.getElementById('uploadStatus');
dropzone.addEventListener('click', function(){ fileInput.click(); });
['dragenter','dragover'].forEach(function(evt){
dropzone.addEventListener(evt, function(e){
e.preventDefault(); e.stopPropagation();
dropzone.classList.add('dragover');
});
});
['dragleave','drop'].forEach(function(evt){
dropzone.addEventListener(evt, function(e){
e.preventDefault(); e.stopPropagation();
dropzone.classList.remove('dragover');
});
});
dropzone.addEventListener('drop', function(e){
var f = e.dataTransfer.files && e.dataTransfer.files[0];
if(f) uploadFile(f);
});
fileInput.addEventListener('change', function(){
if(fileInput.files && fileInput.files[0]) uploadFile(fileInput.files[0]);
fileInput.value = '';
});
function setUploadStatus(cls, msg){
uploadStatus.className = 'upload-status' + (cls ? ' ' + cls : '');
uploadStatus.textContent = msg;
}
function uploadFile(file){
var ext = '.' + file.name.split('.').pop().toLowerCase();
if(ext !== '.txt' && ext !== '.md'){
setUploadStatus('bad', 'Only .txt or .md files are supported.');
return;
}
if(file.size > 5 * 1024 * 1024){
setUploadStatus('bad', 'File too large — max 5MB.');
return;
}
dropzoneText.textContent = 'Uploading ' + file.name + '…';
setUploadStatus('', 'Uploading and queuing re-index…');
var formData = new FormData();
formData.append('domain', uploadDomain.value);
formData.append('file', file);
fetch(apiBase() + '/documents/upload', {
method:'POST',
body: formData
}).then(function(r){
return r.json().then(function(d){
if(!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
return d;
});
}).then(function(d){
dropzoneText.textContent = 'Drop a .txt or .md file here, or click to browse';
setUploadStatus('ok', '✓ ' + d.filename + ' added to ' + d.domain + ' — re-indexing in background.');
}).catch(function(err){
dropzoneText.textContent = 'Drop a .txt or .md file here, or click to browse';
setUploadStatus('bad', 'Upload failed — ' + err.message);
});
}
})();
</script>
</body>
</html>