From b087de8c851cb4ff9adbb86881665ef8415ebfef Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 28 May 2026 10:53:19 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20SPA=20=E2=80=94=205-tab=20l?= =?UTF-8?q?og/search/speakers/RAG/rooms=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/api/static/app.js | 240 ++++++++++++++++++++++++++++++++++++++ app/api/static/index.html | 84 +++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 app/api/static/app.js create mode 100644 app/api/static/index.html diff --git a/app/api/static/app.js b/app/api/static/app.js new file mode 100644 index 0000000..43409e5 --- /dev/null +++ b/app/api/static/app.js @@ -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,'&').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 = `
+ + ${fmtDate(_currentDate + 'T12:00:00')} + + +
`; + + if (!utterances.length) { + panel.innerHTML = nav + '
No utterances recorded for this day.
'; + 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 += `
${timeKey}
`; + for (const u of utts) { + const color = speakerColor(u.speaker_id); + const name = u.speaker_name || 'Unknown'; + const badge = u.match_status === 'unknown' ? 'unknown' + : u.match_status === 'ambiguous' ? 'ambiguous' : ''; + const actions = !u.speaker_id ? `
+ + + +
` : ''; + html += `
+
${speakerInitial(name)}
+
+
+ ${escHtml(name)}${badge} + ${escHtml(u.room_name)} + ${fmtTime(u.start_time)} +
+
${escHtml(u.transcript)}
+ ${actions} +
+
`; + } + html += '
'; + } + 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 = `

Search Transcripts

+
+ + +
+
`; +} + +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 = '
No results.
'; return; } + const re = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'); + container.innerHTML = results.map(r => `
+
${escHtml(r.speaker_name || 'Unknown')} · ${escHtml(r.room_name)} · ${fmtDate(r.start_time)} ${fmtTime(r.start_time)}
+
${escHtml(r.transcript).replace(re, '$1')}
+
`).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 += `
${unreviewed.length} utterances need speaker tags + +
`; + } + html += `

Speakers

`; + for (const s of speakers) { + const color = speakerColor(s.id); + html += `
+
${speakerInitial(s.name)}
+
${escHtml(s.name)}
+
${s.utterance_count} utterances
+
`; + } + html += `
+
+ Add speaker
+
`; + 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 = `

Ask AI

+
+
+ + +
`; + renderRagMessages(); +} + +function renderRagMessages() { + const el = document.getElementById('ragMessages'); + if (!el) return; + if (!_ragHistory.length) { el.innerHTML = '
Ask a question about any conversation.
'; return; } + el.innerHTML = _ragHistory.map(m => `
+
${m.role === 'user' ? 'You' : 'LinkedStorm AI'}
+
${escHtml(m.content)}
+
`).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 = `

Capture Rooms

`; + 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 += `
+
${escHtml(r.name)}
+
+
+ ${statusText} +
+ ${r.last_seen ? `
Last: ${fmtTime(r.last_seen)}
` : ''} + + Open capture page +
`; + } + html += `
`; + panel.innerHTML = html; +} + +async function muteRoom(roomId) { + await fetch(`/api/rooms/${roomId}/mute`, { method: 'POST' }); + loadRooms(); +} + +// Initial load +loadLog(); +setInterval(loadLog, 30000); diff --git a/app/api/static/index.html b/app/api/static/index.html new file mode 100644 index 0000000..08b9262 --- /dev/null +++ b/app/api/static/index.html @@ -0,0 +1,84 @@ + + + + + +LinkedStorm + + + + +
+
+ +
+
+
+
+ + +