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 += `
`;
+ 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)}
+
+ ${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
+
+
+
+
+
+
+
+