Files
2026-05-28 10:53:19 -05:00

241 lines
10 KiB
JavaScript

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);