feat: dashboard SPA — 5-tab log/search/speakers/RAG/rooms UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 10:53:19 -05:00
parent cb7896a103
commit b087de8c85
2 changed files with 324 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
const COLORS = ['#60a5fa','#a78bfa','#f472b6','#34d399','#fb923c','#38bdf8','#e879f9','#a3e635'];
const speakerColor = (id) => COLORS[(id || 0) % COLORS.length];
const speakerInitial = (name) => (name || '?')[0].toUpperCase();
const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
const fmtDate = (iso) => new Date(iso).toLocaleDateString([], {weekday:'long',month:'long',day:'numeric',year:'numeric'});
const today = () => new Date().toISOString().slice(0,10);
let _currentDate = today();
function showPanel(name, el) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('panel-' + name).classList.add('active');
if (el) el.classList.add('active');
if (name === 'log') loadLog();
if (name === 'search') renderSearch();
if (name === 'speakers') loadSpeakers();
if (name === 'rag') renderRag();
if (name === 'rooms') loadRooms();
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// --- Daily Log ---
async function loadLog(date) {
if (date) _currentDate = date;
const panel = document.getElementById('panel-log');
const utterances = await fetch(`/api/utterances?date=${_currentDate}`).then(r => r.json()).catch(() => []);
const nav = `<div class="toolbar">
<button class="btn-secondary btn-small" onclick="loadLog(shiftDate(-1))">Prev</button>
<span style="font-size:0.85em;font-weight:600">${fmtDate(_currentDate + 'T12:00:00')}</span>
<button class="btn-secondary btn-small" onclick="loadLog(shiftDate(1))">Next</button>
<button class="btn-secondary btn-small" onclick="loadLog('${today()}')">Today</button>
</div>`;
if (!utterances.length) {
panel.innerHTML = nav + '<div class="empty">No utterances recorded for this day.</div>';
return;
}
const byHour = {};
for (const u of [...utterances].reverse()) {
const key = new Date(u.start_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
if (!byHour[key]) byHour[key] = [];
byHour[key].push(u);
}
let html = nav;
for (const [timeKey, utts] of Object.entries(byHour)) {
html += `<div class="time-block"><div class="time-label">${timeKey}</div>`;
for (const u of utts) {
const color = speakerColor(u.speaker_id);
const name = u.speaker_name || 'Unknown';
const badge = u.match_status === 'unknown' ? '<span class="badge badge-unknown">unknown</span>'
: u.match_status === 'ambiguous' ? '<span class="badge badge-ambiguous">ambiguous</span>' : '';
const actions = !u.speaker_id ? `<div class="utt-actions">
<button class="btn-small btn-secondary" onclick="playClip(${u.id})">Play</button>
<button class="btn-small btn-secondary" onclick="tagSpeaker(${u.id})">Tag</button>
<button class="btn-small btn-secondary" onclick="dismiss(${u.id})">Dismiss</button>
</div>` : '';
html += `<div class="utterance">
<div class="avatar" style="background:${color}22;color:${color}">${speakerInitial(name)}</div>
<div class="utt-body">
<div class="utt-meta">
<span class="utt-name" style="color:${color}">${escHtml(name)}${badge}</span>
<span class="utt-room">${escHtml(u.room_name)}</span>
<span class="utt-time">${fmtTime(u.start_time)}</span>
</div>
<div class="utt-text">${escHtml(u.transcript)}</div>
${actions}
</div>
</div>`;
}
html += '</div>';
}
panel.innerHTML = html;
}
function shiftDate(days) {
const d = new Date(_currentDate + 'T12:00:00');
d.setDate(d.getDate() + days);
return d.toISOString().slice(0,10);
}
async function playClip(id) {
const a = new Audio(`/api/utterances/${id}/clip`);
a.play();
}
async function tagSpeaker(utteranceId) {
const speakers = await fetch('/api/speakers').then(r => r.json());
const name = prompt('Speaker name:\n' + speakers.map(s => '- ' + s.name).join('\n'));
if (!name) return;
const existing = speakers.find(s => s.name.toLowerCase() === name.trim().toLowerCase());
const body = existing ? { speaker_id: existing.id } : { name: name.trim(), create_new: true };
await fetch(`/api/utterances/${utteranceId}/tag`, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
loadLog();
}
async function dismiss(id) {
await fetch(`/api/utterances/${id}/dismiss`, { method: 'POST' });
loadLog();
}
// --- Search ---
function renderSearch() {
const panel = document.getElementById('panel-search');
panel.innerHTML = `<h2>Search Transcripts</h2>
<div class="toolbar">
<input id="searchQ" placeholder="Search..." style="flex:1" onkeydown="if(event.key==='Enter')doSearch()">
<button class="btn-primary" onclick="doSearch()">Search</button>
</div>
<div id="searchResults"></div>`;
}
async function doSearch() {
const q = document.getElementById('searchQ').value.trim();
if (!q) return;
const results = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then(r => r.json()).catch(() => []);
const container = document.getElementById('searchResults');
if (!results.length) { container.innerHTML = '<div class="empty">No results.</div>'; return; }
const re = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi');
container.innerHTML = results.map(r => `<div class="search-result">
<div class="sr-meta">${escHtml(r.speaker_name || 'Unknown')} &middot; ${escHtml(r.room_name)} &middot; ${fmtDate(r.start_time)} ${fmtTime(r.start_time)}</div>
<div class="sr-text">${escHtml(r.transcript).replace(re, '<mark>$1</mark>')}</div>
</div>`).join('');
}
// --- Speakers ---
async function loadSpeakers() {
const panel = document.getElementById('panel-speakers');
const [speakers, unreviewed] = await Promise.all([
fetch('/api/speakers').then(r => r.json()).catch(() => []),
fetch('/api/utterances?match_status=unreviewed').then(r => r.json()).catch(() => []),
]);
let html = '';
if (unreviewed.length) {
html += `<div class="review-banner">${unreviewed.length} utterances need speaker tags
<button class="btn-small btn-secondary" onclick="showPanel('log',null)">Review</button>
</div>`;
}
html += `<h2>Speakers</h2><div class="speaker-grid">`;
for (const s of speakers) {
const color = speakerColor(s.id);
html += `<div class="speaker-card">
<div class="sp-avatar" style="background:${color}22;color:${color}">${speakerInitial(s.name)}</div>
<div class="sp-name">${escHtml(s.name)}</div>
<div class="sp-count">${s.utterance_count} utterances</div>
</div>`;
}
html += `<div class="speaker-card" onclick="createSpeaker()" style="border-style:dashed;cursor:pointer;display:flex;align-items:center;justify-content:center;min-height:80px">
<div style="color:#475569;font-size:0.8em">+ Add speaker</div>
</div></div>`;
panel.innerHTML = html;
}
async function createSpeaker() {
const name = prompt('Speaker name:');
if (!name) return;
await fetch('/api/speakers', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name}) });
loadSpeakers();
}
// --- Ask AI ---
const _ragHistory = [];
function renderRag() {
const panel = document.getElementById('panel-rag');
panel.innerHTML = `<h2>Ask AI</h2>
<div id="ragMessages" style="margin-bottom:16px;min-height:100px"></div>
<div class="toolbar">
<input id="ragQ" placeholder="Ask about any conversation..." style="flex:1" onkeydown="if(event.key==='Enter')askRag()">
<button class="btn-primary" onclick="askRag()">Ask</button>
</div>`;
renderRagMessages();
}
function renderRagMessages() {
const el = document.getElementById('ragMessages');
if (!el) return;
if (!_ragHistory.length) { el.innerHTML = '<div class="empty">Ask a question about any conversation.</div>'; return; }
el.innerHTML = _ragHistory.map(m => `<div class="rag-msg rag-${m.role}">
<div class="rag-label">${m.role === 'user' ? 'You' : 'LinkedStorm AI'}</div>
<div class="rag-bubble">${escHtml(m.content)}</div>
</div>`).join('');
el.scrollTop = el.scrollHeight;
}
async function askRag() {
const q = document.getElementById('ragQ')?.value.trim();
if (!q) return;
document.getElementById('ragQ').value = '';
_ragHistory.push({ role: 'user', content: q });
renderRagMessages();
const resp = await fetch('/api/ask', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ question: q, date: _currentDate }),
}).then(r => r.json()).catch(() => ({ answer: 'Error contacting AI.' }));
_ragHistory.push({ role: 'ai', content: resp.answer || 'No response.' });
renderRagMessages();
}
// --- Rooms ---
async function loadRooms() {
const panel = document.getElementById('panel-rooms');
const rooms = await fetch('/api/rooms').then(r => r.json()).catch(() => []);
let html = `<h2>Capture Rooms</h2><div class="room-grid">`;
for (const r of rooms) {
const active = r.is_active;
const dotColor = active ? '#4ade80' : '#475569';
const statusText = active ? 'Streaming' : 'Disconnected';
const cls = active ? 'streaming' : '';
html += `<div class="room-card ${cls}">
<div class="room-name">${escHtml(r.name)}</div>
<div class="status-row">
<div class="sdot" style="background:${dotColor}"></div>
<span style="color:${dotColor}">${statusText}</span>
</div>
${r.last_seen ? `<div style="font-size:0.7em;color:#475569;margin-top:4px">Last: ${fmtTime(r.last_seen)}</div>` : ''}
<button class="btn-small btn-secondary" style="margin-top:8px;width:100%" onclick="muteRoom(${r.id})">Mute</button>
<a href="/capture?room=${encodeURIComponent(r.name)}" target="_blank"
style="display:block;margin-top:6px;font-size:0.7em;color:#64748b;text-align:center">Open capture page</a>
</div>`;
}
html += `</div>`;
panel.innerHTML = html;
}
async function muteRoom(roomId) {
await fetch(`/api/rooms/${roomId}/mute`, { method: 'POST' });
loadRooms();
}
// Initial load
loadLog();
setInterval(loadLog, 30000);
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LinkedStorm</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;height:100vh;overflow:hidden}
.sidebar{width:180px;flex-shrink:0;background:#1e293b;display:flex;flex-direction:column;border-right:1px solid #334155}
.brand{padding:16px;font-weight:700;font-size:1em;color:#4ade80;border-bottom:1px solid #334155}
.nav-item{padding:10px 16px;font-size:0.85em;color:#94a3b8;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all 0.1s}
.nav-item:hover{background:#334155;color:#e2e8f0}
.nav-item.active{background:rgba(74,222,128,0.1);color:#4ade80;border-left:3px solid #4ade80}
.main{flex:1;overflow:auto;background:#0f172a}
.panel{display:none;padding:20px;height:100%}
.panel.active{display:block}
.toolbar{display:flex;gap:8px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
input,select{background:#1e293b;border:1px solid #334155;border-radius:6px;padding:7px 10px;color:#e2e8f0;font-size:0.85em}
input:focus,select:focus{outline:none;border-color:#4ade80}
button{padding:7px 14px;border-radius:6px;border:none;cursor:pointer;font-size:0.85em;transition:all 0.15s}
.btn-primary{background:#4ade80;color:#052e16;font-weight:600}
.btn-secondary{background:#334155;color:#e2e8f0}
.btn-small{padding:3px 8px;font-size:0.72em}
.time-block{margin-bottom:16px}
.time-label{font-size:0.7em;text-transform:uppercase;letter-spacing:0.06em;color:#475569;margin-bottom:6px;display:flex;align-items:center;gap:8px}
.time-label::after{content:'';flex:1;height:1px;background:#1e293b}
.utterance{display:flex;gap:10px;margin-bottom:6px;align-items:flex-start}
.avatar{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75em;font-weight:700;flex-shrink:0;margin-top:2px}
.utt-body{flex:1;background:#1e293b;border-radius:8px;padding:8px 12px}
.utt-meta{display:flex;gap:8px;align-items:center;margin-bottom:3px;font-size:0.72em}
.utt-name{font-weight:600}
.utt-room{color:#64748b}
.utt-time{color:#475569;margin-left:auto}
.utt-text{font-size:0.82em;color:#cbd5e1;line-height:1.45}
.utt-actions{display:flex;gap:4px;margin-top:5px}
.badge{font-size:0.62em;padding:1px 5px;border-radius:4px;margin-left:4px}
.badge-unknown{background:rgba(239,68,68,0.2);color:#f87171}
.badge-ambiguous{background:rgba(245,158,11,0.2);color:#fbbf24}
.search-result{background:#1e293b;border-radius:8px;padding:10px 12px;margin-bottom:8px;border-left:3px solid #334155}
.sr-meta{font-size:0.7em;color:#64748b;margin-bottom:3px}
.sr-text{font-size:0.82em;color:#cbd5e1}
mark{background:rgba(74,222,128,0.2);color:#4ade80;border-radius:2px;padding:0 2px}
.speaker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.speaker-card{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:14px;text-align:center}
.sp-avatar{width:44px;height:44px;border-radius:50%;margin:0 auto 8px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:1.1em}
.sp-name{font-size:0.85em;font-weight:600}
.sp-count{font-size:0.72em;color:#64748b;margin-top:2px}
.room-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px}
.room-card{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:14px}
.room-card.streaming{border-color:#4ade80}
.room-card.muted{border-color:#ef4444}
.room-name{font-size:0.9em;font-weight:600;margin-bottom:6px}
.status-row{display:flex;align-items:center;gap:6px;font-size:0.75em}
.sdot{width:8px;height:8px;border-radius:50%}
.rag-msg{margin-bottom:12px}
.rag-bubble{border-radius:10px;padding:10px 14px;font-size:0.85em;line-height:1.5;color:#cbd5e1}
.rag-user .rag-bubble{background:rgba(74,222,128,0.08);border:1px solid rgba(74,222,128,0.2);margin-left:15%}
.rag-ai .rag-bubble{background:#1e293b;border:1px solid #334155;margin-right:15%}
.rag-label{font-size:0.68em;color:#64748b;margin-bottom:3px;padding:0 4px}
.review-banner{background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);border-radius:8px;padding:10px 14px;margin-bottom:14px;display:flex;justify-content:space-between;align-items:center;font-size:0.82em;color:#fbbf24}
h2{font-size:1em;font-weight:600;margin-bottom:16px;color:#f1f5f9}
.empty{color:#475569;font-size:0.85em;padding:20px 0}
</style>
</head>
<body>
<div class="sidebar">
<div class="brand">LinkedStorm</div>
<div class="nav-item active" onclick="showPanel('log',this)">Daily Log</div>
<div class="nav-item" onclick="showPanel('search',this)">Search</div>
<div class="nav-item" onclick="showPanel('speakers',this)">Speakers</div>
<div class="nav-item" onclick="showPanel('rag',this)">Ask AI</div>
<div class="nav-item" onclick="showPanel('rooms',this)">Rooms</div>
</div>
<div class="main">
<div id="panel-log" class="panel active"></div>
<div id="panel-search" class="panel"></div>
<div id="panel-speakers" class="panel"></div>
<div id="panel-rag" class="panel"></div>
<div id="panel-rooms" class="panel"></div>
</div>
<script src="/app.js"></script>
</body>
</html>