b087de8c85
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
241 lines
10 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
// --- 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')} · ${escHtml(r.room_name)} · ${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);
|