import './style.css'; const api = { async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); }, async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, async patch(u, b) { const r = await fetch(u, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); } }; const app = document.querySelector('#app'); const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '', // search: `q` is what was actually submitted (not what is being typed), so a // tab switch and back restores the result page instead of an empty form. searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false, // Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und // welche Ebene darin (0 = direkt im Ordner, sonst die id des Unterreiters). projectId: null, projectTabId: 0, // Kontextnotiz: `ctxPromptFor` = die Aufnahme, deren Kontextbox nach dem Stop // aufgeklappt und fokussiert dasteht (bis gespeichert oder weggeklickt). // `ctxDrafts` hält getippten, noch nicht gespeicherten Text pro Aufnahme — // die Detailansicht baut sich nach der Analyse komplett neu, sonst wäre das // Getippte weg. ctxPromptFor: null, ctxDrafts: {} }; function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); } // Theme: data-theme on , chosen in index.html before first paint (no // flash); the toggle persists to localStorage. function applyTheme(t) { document.documentElement.dataset.theme = t; localStorage.setItem('aime-theme', t); const b = document.querySelector('#themetoggle'); if (b) b.textContent = t === 'dark' ? '☀️' : '🌙'; } function render() { const theme = document.documentElement.dataset.theme || 'dark'; app.innerHTML = `

Aime Anruf-Assistent

${state.recording ? fmt(Date.now() - state.recStart) : '00:00'} ${state.recording ? ` 🏷 ${state.markers.length}` : ''}
${sysmonMarkup()}
`; app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); }); // Logo + Schriftzug = Heimweg zurück auf die Aufnahmen. app.querySelector('#brandhome').onclick = () => { if (state.tab === 'recordings') return; state.tab = 'recordings'; render(); }; app.querySelector('#themetoggle').onclick = () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); app.querySelector('#recbtn').onclick = toggleRecord; paintSysmon(sysmon.last); // Werte sofort zurückmalen, render() leert das DOM startSysmon(); bindMarkerInput(); if (state.recording) startTimerUI(); ({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab](); } // ---- Systemanzeige in der Kopfzeile ------------------------------------- // Zwei Symbole zwischen den Tabs und dem Hell/Dunkel-Schalter: ein Ring mit der // Plattenbelegung in Prozent und eine Turbine, die sich um so schneller dreht, // je höher die CPU-Last ist. Bei Mauskontakt klappt jeweils eine Karte mit den // Zahlen auf (CPU, RAM, Temperatur bzw. belegt/frei/gesamt). const sysmon = { timer: null, last: null }; // Ventilator: vier gebogene Flügel im feststehenden Gehäusering. Nur die // Flügelgruppe dreht sich, der Ring bleibt stehen — sonst sähe man die // Drehung am runden Gehäuse gar nicht. const FAN_BLADES = [0, 90, 180, 270].map(a => ``).join(''); function sysmonMarkup() { return `
–
Speicher für Aufnahmen
Aufnahmen–
Noch frei–
Gesamt für Aufnahmen–
100 % = freier Platz + alle vorhandenen Aufnahmen
(also der Platz eines frisch aufgesetzten Systems)
Partition gesamt–
Systemlast
CPU–
RAM–
Temperatur–
`; } function sysLevel(pct) { return pct >= 90 ? 'danger' : pct >= 75 ? 'warn' : 'ok'; } function sysGb(bytes) { return (bytes / 1073741824).toFixed(1).replace('.', ',') + ' GB'; } // Drehzahl in Stufen statt stufenlos: ein geänderter animation-duration setzt // die laufende Drehung neu an, das ruckt. Grobe Stufen = seltener Sprung. function spinSeconds(cpuPct) { const steps = [[5, 7], [15, 4.5], [30, 3], [45, 2], [60, 1.3], [75, .85], [90, .55]]; for (const [limit, sec] of steps) if (cpuPct < limit) return sec; return .35; } function paintSysmon(s) { const disk = document.querySelector('#sysdisk'); const load = document.querySelector('#sysload'); if (!disk || !load) return; const set = (sel, txt) => { const el = document.querySelector(sel); if (el) el.textContent = txt; }; if (!s) { // Server nicht erreichbar: Anzeige grau, Turbine steht disk.className = 'sys-chip off'; load.className = 'sys-chip off'; return; } if (s.disk) { // Prozent = Aufnahmen / (freier Platz + Aufnahmen). Das System selbst zählt // bewusst nicht mit: gezeigt wird, wie viel vom Aufnahme-Vorrat weg ist. const p = Math.round(s.disk.usedPct); disk.className = 'sys-chip ' + sysLevel(p); disk.title = `Aufnahmen belegen ${p} % des verfügbaren Platzes`; set('[data-disk-pct]', p + '%'); const ring = document.querySelector('[data-disk-ring]'); if (ring) ring.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, s.disk.usedPct)).toFixed(1)} 100`); const db = document.querySelector('[data-disk-bar]'); if (db) db.style.width = Math.min(100, s.disk.usedPct) + '%'; set('[data-disk-used]', `${sysGb(s.disk.recBytes)}${s.disk.recFiles ? ` · ${s.disk.recFiles} Dateien` : ''}`); set('[data-disk-free]', sysGb(s.disk.freeBytes)); set('[data-disk-total]', sysGb(s.disk.budgetBytes)); set('[data-disk-part]', `${sysGb(s.disk.totalBytes)} · ${Math.round(s.disk.partPct)} % belegt`); } const cpu = s.cpu ? s.cpu.pct : 0; const ram = s.ram ? s.ram.usedPct : 0; const tc = s.temp ? s.temp.c : null; // Die Farbe der Turbine folgt dem schlimmsten der drei Werte; Temperatur ab // 80 °C gelb, ab 90 °C rot (darüber drosselt der RK3588 sichtbar). const worst = Math.max(cpu, ram, tc == null ? 0 : (tc - 45) / 45 * 100); load.className = 'sys-chip ' + sysLevel(worst); load.title = `CPU ${Math.round(cpu)} % · RAM ${Math.round(ram)} %${tc == null ? '' : ` · ${tc.toFixed(0)} °C`}`; set('[data-cpu-pct]', `${cpu.toFixed(0)} %${s.cpu && s.cpu.cores ? ` (${s.cpu.cores} Kerne)` : ''}`); set('[data-ram-pct]', s.ram ? `${ram.toFixed(0)} % — ${sysGb(s.ram.usedBytes)} von ${sysGb(s.ram.totalBytes)}` : '–'); set('[data-temp]', tc == null ? '–' : `${tc.toFixed(1)} °C${s.temp.zone ? ` (${s.temp.zone})` : ''}`); const cb = document.querySelector('[data-cpu-bar]'); if (cb) cb.style.width = Math.min(100, cpu) + '%'; const rb = document.querySelector('[data-ram-bar]'); if (rb) rb.style.width = Math.min(100, ram) + '%'; const tb = document.querySelector('[data-turbine]'); if (tb) tb.style.setProperty('--spin', spinSeconds(cpu) + 's'); } async function tickSysmon() { if (document.hidden) return; // im Hintergrund kein Polling try { sysmon.last = await api.get('/api/sysstat'); } catch (err) { sysmon.last = null; } paintSysmon(sysmon.last); } function startSysmon() { if (sysmon.timer) return; // ein Ticker reicht für alle render() tickSysmon(); sysmon.timer = setInterval(tickSysmon, 5000); document.addEventListener('visibilitychange', () => { if (!document.hidden) tickSysmon(); }); } // Header record button: start = defaults only (default source = CallRecMix, // codec auto, speaker count auto — the old options tab is gone on purpose). // A live placeholder card appears at the top of the recordings list instantly. async function toggleRecord() { const btn = document.querySelector('#recbtn'); if (!state.recording) { try { await api.post('/api/record/start', {}); state.recording = true; state.recStart = Date.now(); state.markers = []; state.mkDraft = ''; render(); if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); } } catch (err) { toast(err.message, true); } } else { if (btn) { btn.disabled = true; } clearInterval(state.timer); state.timer = null; try { const r = await api.post('/api/record/stop', {}); state.recording = false; toast('Aufnahme gespeichert – wird transkribiert…'); // Kontextbox der frischen Aufnahme aufgeklappt anbieten: die Transkription // läuft jetzt minutenlang, der getippte Kontext ist also rechtzeitig da, // wenn die KI-Analyse danach startet (server.js analyzeRecording). state.ctxPromptFor = r.id; state.tab = 'recordings'; render(); state.lastListSig = null; refreshRecList(); openRecording(r.id); } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } } } // ---------- live speaker markers ---------- // While recording: type the name of whoever is talking, Enter. The server stamps // the offset; after diarization the name lands on that acoustic speaker. Only // reliable way to tell 20+ conference participants apart. function bindMarkerInput() { const inp = document.querySelector('#mkname'); if (!inp) return; inp.oninput = () => { state.mkDraft = inp.value; }; inp.onkeydown = async (e) => { if (e.key !== 'Enter') return; const name = inp.value.trim(); if (!name) return; inp.value = ''; state.mkDraft = ''; try { const r = await api.post('/api/record/marker', { name }); state.markers = r.markers || []; updateMarkerUI(); toast(`🏷 ${fmt(r.t_ms)} ${name}`); } catch (err) { toast('Marker fehlgeschlagen: ' + err.message, true); } }; } // Refresh the marker count + chip list in place (no re-render → focus stays in // the input, so names can be typed back-to-back). function updateMarkerUI() { const c = document.querySelector('#mkcount'); if (c) { c.textContent = '🏷 ' + state.markers.length; c.classList.toggle('ok', !!state.markers.length); } const chips = document.querySelector('#mkchips'); if (chips) chips.innerHTML = markerChipsHtml(); bindMarkerChips(); } function markerChipsHtml() { if (!state.markers.length) return 'noch keine Namen markiert'; return state.markers.map((m, i) => `${fmt(m.t_ms)} ${escapeHtml(m.name)}`).join(''); } function bindMarkerChips() { document.querySelectorAll('.mkdel').forEach(b => b.onclick = async (e) => { e.stopPropagation(); try { const r = await api.del('/api/record/marker/' + b.dataset.mk); state.markers = r.markers || []; updateMarkerUI(); } catch (err) { toast(err.message, true); } }); } // Tick every .rectime element (header + list placeholder) from one interval. function startTimerUI() { clearInterval(state.timer); state.timer = setInterval(() => { const t = fmt(Date.now() - state.recStart); document.querySelectorAll('[data-rectime]').forEach(el => el.textContent = t); }, 500); } // ---------- Bluetooth ---------- async function viewBluetooth() { const v = document.querySelector('#view'); v.innerHTML = `
Lade…
`; const listEl = v.querySelector('#btlist'); const msg = v.querySelector('#btmsg'); async function load(devices) { const d = devices || await api.get('/api/bt/devices'); if (!d.length) { listEl.innerHTML = '
Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).
'; return; } listEl.innerHTML = d.map(dev => `
${dev.name || '(unbenannt)'} ${dev.audio ? '🎧' : ''}
${dev.mac}
${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'} ${dev.connected ? `` : dev.paired ? `` : ``} ${dev.audio ? `` : ''}
`).join(''); listEl.querySelectorAll('button[data-act]').forEach(b => b.onclick = () => act(b.dataset.act, b.dataset.mac, b)); } async function act(a, mac, btn) { btn.disabled = true; btn.textContent = a === 'repair' ? 'neu koppeln… (~15s)' : '…'; try { const r = await api.post('/api/bt/' + a, { mac }); if (a === 'repair') toast(r.ok ? 'neu verbunden ✓' : 'fehlgeschlagen: ' + (r.msg || r.connect || 'siehe Headset im Pairing-Modus?'), !r.ok); else toast(`${a}: ${r.connected ? 'verbunden' : 'ok'}`); if ((a === 'pair' || a === 'connect' || a === 'repair') && (r.connected || r.ok)) await api.post('/api/audio/headset-mic', {}); } catch (e) { toast(a + ' fehlgeschlagen: ' + e.message, true); } await load(); } v.querySelector('#scan').onclick = async (e) => { e.target.disabled = true; msg.textContent = 'suche…'; try { await load(await api.post('/api/bt/scan', { seconds: 10 })); } catch (err) { toast(err.message, true); } msg.textContent = ''; e.target.disabled = false; }; v.querySelector('#refresh').onclick = () => load(); load().catch(e => listEl.innerHTML = '
' + e.message + '
'); } // ---------- Voices (enrollment) ---------- async function viewVoices() { const v = document.querySelector('#view'); v.innerHTML = `

Stimme anlernen

Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
00:00

Personen

Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.
Lade…
`; const listEl = v.querySelector('#vplist'); async function load() { try { const persons = await api.get('/api/persons'); const opts = persons.map(p => ``).join(''); listEl.innerHTML = persons.length ? persons.map(p => `
👤 ${escapeHtml(p.display_name)}
${p.names.map(n => `${escapeHtml(n)}${p.names.length > 1 ? `` : ''}`).join('')}
${p.voiceprints.length} Stimmprobe${p.voiceprints.length === 1 ? '' : 'n'}${p.last_print ? ' · letzte ' + new Date(p.last_print).toLocaleDateString('de-DE') : ''}${p.voiceprints.some(x => x.stale) ? ' · ⚠ andere Modellversion' : ''}
${persons.length > 1 ? `` : ''} ${p.voiceprints.length ? `` : ''}
`).join('') : '
Noch keine Personen. Oben eine Stimme anlernen oder in einer Aufnahme einen Sprecher benennen.
'; listEl.querySelectorAll('[data-addname]').forEach(b => b.onclick = async () => { const name = prompt('Weiterer Name für diese Person:'); if (!name || !name.trim()) return; try { const r = await api.post(`/api/persons/${b.dataset.addname}/names`, { name: name.trim() }); toast(r.merged ? 'Name gehörte einer anderen Person → zusammengeführt ✓' : 'Name hinzugefügt ✓'); load(); } catch (e) { toast(e.message, true); } }); listEl.querySelectorAll('[data-delname]').forEach(b => b.onclick = async () => { try { await api.del('/api/persons/names/' + b.dataset.delname); load(); } catch (e) { toast(e.message, true); } }); listEl.querySelectorAll('[data-merge]').forEach(sel => sel.onchange = async () => { const fromId = sel.value; if (!fromId) return; if (fromId === sel.dataset.merge) { sel.value = ''; return; } if (!confirm('Beide Personen als EIN Mensch führen? Namen und Stimmproben wandern zusammen.')) { sel.value = ''; return; } try { await api.post(`/api/persons/${sel.dataset.merge}/merge`, { fromId: +fromId }); toast('Zusammengeführt ✓'); load(); } catch (e) { toast(e.message, true); } }); listEl.querySelectorAll('[data-clear]').forEach(b => b.onclick = async () => { if (!confirm('Alle Stimmproben dieser Person löschen? Die Person und ihre Namen bleiben.')) return; const p = persons.find(x => x.id === +b.dataset.clear); for (const vp of p.voiceprints) await api.del('/api/voiceprints/' + vp.id); load(); }); listEl.querySelectorAll('[data-delperson]').forEach(b => b.onclick = async () => { if (!confirm('Person samt allen Namen und Stimmproben löschen?')) return; await api.del('/api/persons/' + b.dataset.delperson); load(); }); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } } await load(); const btn = v.querySelector('#vbtn'), msg = v.querySelector('#vmsg'), timerEl = v.querySelector('#vtimer'); if (state.enrolling) startTimerUI(timerEl); btn.onclick = async () => { if (!state.enrolling) { const name = v.querySelector('#vname').value.trim(); if (!name) { toast('Bitte Namen eingeben', true); return; } state.enrollName = name; try { await api.post('/api/enroll/start', {}); state.enrolling = true; state.recStart = Date.now(); render(); } catch (e) { toast(e.message, true); } } else { btn.disabled = true; btn.textContent = 'Speichere…'; clearInterval(state.timer); try { const r = await api.post('/api/enroll/stop', { name: state.enrollName }); state.enrolling = false; state.enrollName = ''; toast(`Stimme „${r.name}" gespeichert ✓`); render(); } catch (e) { state.enrolling = false; toast('Fehlgeschlagen: ' + e.message, true); render(); } } }; } // ---------- Glossary ---------- async function viewGlossary() { const v = document.querySelector('#view'); v.innerHTML = `

Begriff hinzufügen

Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.

Glossar

Lade…
`; const listEl = v.querySelector('#glist'); let editId = null; // id of the entry currently loaded into the form const formTitle = v.querySelector('#gformtitle'), saveBtn = v.querySelector('#gadd'), cancelBtn = v.querySelector('#gcancel'); function setEdit(g) { editId = g ? g.id : null; formTitle.textContent = g ? `„${g.term}" bearbeiten` : 'Begriff hinzufügen'; saveBtn.textContent = g ? 'Speichern' : 'Hinzufügen'; cancelBtn.style.display = g ? '' : 'none'; v.querySelector('#gterm').value = g ? g.term : ''; v.querySelector('#gsound').value = g ? (g.soundalikes || '') : ''; if (g) v.querySelector('#gterm').focus(); } async function load() { try { const gs = await api.get('/api/glossary'); listEl.innerHTML = gs.length ? gs.map(g => `
📖 ${escapeHtml(g.term)} ${g.soundalikes ? `
↔ ${escapeHtml(g.soundalikes)}
` : ''}
`).join('') : '
Noch keine Begriffe. Oben hinzufügen.
'; listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/glossary/' + b.dataset.del); if (editId === +b.dataset.del) setEdit(null); load(); }); listEl.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => setEdit(gs.find(g => g.id === +b.dataset.edit))); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } } await load(); cancelBtn.onclick = () => setEdit(null); saveBtn.onclick = async () => { const term = v.querySelector('#gterm').value.trim(); if (!term) { toast('Bitte Begriff eingeben', true); return; } const soundalikes = v.querySelector('#gsound').value.trim(); try { if (editId != null) await api.put('/api/glossary/' + editId, { term, soundalikes }); else await api.post('/api/glossary', { term, soundalikes }); toast(`„${term}" gespeichert ✓`); setEdit(null); load(); } catch (e) { toast(e.message, true); } }; } // ---------- Search ---------- // Two-stage on purpose (see search.js): the hit list is SQL and lands in // milliseconds, the written answer is a ~30-90 s LLM call that streams in // afterwards. Never make the user wait on the slow half to see the fast half. const SEARCH_EXAMPLES = [ 'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?', 'Was wurde über die Preiserhöhung besprochen?', 'Welche Aufgaben hat Markus im Juli übernommen?', ]; function viewSearch() { const v = document.querySelector('#view'); const hasResults = !!state.searchRes; v.innerHTML = `
${hasResults ? '' : `
Durchsucht alle Mitschriften. Beispiele:
${SEARCH_EXAMPLES.map(e => ``).join('')}
`}
`; const input = v.querySelector('#searchq'); v.querySelector('#searchform').onsubmit = (e) => { e.preventDefault(); runSearch(input.value.trim()); }; const clr = v.querySelector('#searchclear'); if (clr) clr.onclick = () => { state.searchQ = ''; state.searchRes = null; state.searchAnswer = null; viewSearch(); }; v.querySelectorAll('.example').forEach(b => b.onclick = () => { input.value = b.dataset.ex; runSearch(b.dataset.ex); }); if (!hasResults) input.focus(); if (hasResults) renderSearchResults(); } async function runSearch(q) { if (!q || q.length < 2) return; state.searchQ = q; state.searchAnswer = null; state.searchBusy = true; const out = document.querySelector('#searchout'); if (out) out.innerHTML = '
Suche…
'; document.querySelector('.search-page').classList.add('has-results'); try { state.searchRes = await api.get('/api/search?q=' + encodeURIComponent(q)); } catch (e) { state.searchRes = null; if (out) out.innerHTML = '
' + escapeHtml(e.message) + '
'; state.searchBusy = false; return; } state.searchBusy = false; renderSearchResults(); // Only bother the NPU when there is something to summarise. if (state.searchRes.hits.length) askSearchAnswer(q); } // The written answer. Fired after the hits are already on screen; the box shows // a live elapsed counter because on this hardware it genuinely takes a while. async function askSearchAnswer(q) { const box = document.querySelector('#answerbox'); if (!box) return; const t0 = Date.now(); const tick = setInterval(() => { const el = box.querySelector('.answer-elapsed'); if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s'; }, 1000); try { const a = await api.post('/api/search/answer', { q }); // A late answer to a query the user has already replaced must not overwrite // the newer result. if (state.searchQ !== q) return; state.searchAnswer = a; renderAnswer(); } catch (e) { if (state.searchQ !== q) return; box.innerHTML = `
Antwort
${escapeHtml(e.message)}
`; } finally { clearInterval(tick); } } // Highlight the query's content words inside a snippet, the way a search engine // bolds the matched terms. Escaped first, so the highlight cannot inject markup. function highlight(text, terms) { let html = escapeHtml(text); for (const t of (terms || [])) { if (t.length < 3) continue; const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); html = html.replace(new RegExp(`(${safe})`, 'gi'), '$1'); } return html; } function renderSearchResults() { const out = document.querySelector('#searchout'); if (!out || !state.searchRes) return; const { hits, terms, range, semantic, total } = state.searchRes; if (!hits.length) { out.innerHTML = `
Keine Treffer für „${escapeHtml(state.searchQ)}".
${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''} Andere Formulierung oder weniger Wörter versuchen.
`; return; } out.innerHTML = `
Antwort …
${total} Fundstelle${total === 1 ? '' : 'n'}${range ? ` · Zeitraum: ${escapeHtml(range.label)}` : ''} · ${semantic ? 'Volltext + semantisch' : 'nur Volltext'}
    ${hits.map(h => `
  1. ${escapeHtml(new Date(h.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }))} · Aufnahme #${h.recording_id}${h.speaker_name ? ' · ' + escapeHtml(h.speaker_name) : ''}
    ${escapeHtml(h.title) || 'Aufnahme #' + h.recording_id} bei ${fmt(h.t_start_ms)}
    ${highlight(h.text, terms)}
  2. `).join('')}
`; out.querySelectorAll('.result').forEach(li => li.onclick = (e) => { e.preventDefault(); openFromSearch(+li.dataset.rec, +li.dataset.t); }); } function renderAnswer() { const box = document.querySelector('#answerbox'); if (!box || !state.searchAnswer) return; const { answer, sources } = state.searchAnswer; box.innerHTML = `
Antwort
${escapeHtml(answer)}
${sources && sources.length ? `
${sources.map(s => ``).join('')}
` : ''}`; box.querySelectorAll('.src-chip').forEach(b => b.onclick = () => openFromSearch(+b.dataset.rec, +b.dataset.t)); } // Jump from a hit into the recording, positioned at the spoken minute. The // detail pane lives in the recordings tab, so switch there first and wait for // the player element to exist before seeking. async function openFromSearch(recId, tMs) { state.tab = 'recordings'; state.selectedId = recId; state.trashOpen = false; render(); for (let i = 0; i < 40; i++) { const p = document.querySelector('#player'); if (p) { const seek = () => { try { p.currentTime = tMs / 1000; p.play().catch(() => {}); } catch {} }; if (p.readyState >= 1) seek(); else p.addEventListener('loadedmetadata', seek, { once: true }); return; } await new Promise(r => setTimeout(r, 100)); } } // ---------- Modal ---------- // Kleiner Dialog-Baustein für die Pin-Auswahl. Bewusst kein Framework: Inhalt // als HTML rein, `onOk` bekommt den Wurzelknoten und kann die Felder auslesen; // gibt onOk `false` zurück, bleibt der Dialog offen (Validierungsfehler). function openModal(title, bodyHtml, { okText = 'Speichern', onOk, onReady } = {}) { const back = document.createElement('div'); back.className = 'modal-back'; back.innerHTML = ` `; document.body.appendChild(back); const root = back.querySelector('.modal'); const close = () => { document.removeEventListener('keydown', onKey); back.remove(); }; const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', onKey); back.onclick = (e) => { if (e.target === back) close(); }; back.querySelector('[data-cancel]').onclick = close; const ok = back.querySelector('[data-ok]'); if (ok && onOk) ok.onclick = async () => { ok.disabled = true; try { if (await onOk(root, close) !== false) close(); } catch (e) { toast(e.message, true); } finally { ok.disabled = false; } }; if (onReady) onReady(root, close); return { root, close }; } // ---------- Projektansicht ---------- // Zwei Ebenen: Ordner („Project X") und darin Unterreiter („Projektrelevante // Aufzeichnungen"). Auf BEIDEN Ebenen lassen sich Aufnahmen anpinnen; ein Pin // ist nur eine Referenz — die Aufnahme bleibt in der Aufnahmenliste, kann in // mehreren Projekten hängen und wird beim Entpinnen nicht gelöscht. function viewProjects() { const v = document.querySelector('#view'); if (state.projectId == null) return renderProjectList(v); return renderProjectDetail(v); } async function renderProjectList(v) { v.innerHTML = `

Projekte

Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.
Lade…
`; v.querySelector('#pnew').onclick = async () => { const name = prompt('Name des Projektordners:', ''); if (name === null || !name.trim()) return; try { const p = await api.post('/api/projects', { name: name.trim() }); toast(`Ordner „${p.name}" angelegt`); state.projectId = p.id; state.projectTabId = 0; viewProjects(); } catch (e) { toast(e.message, true); } }; const listEl = v.querySelector('#plist'); let ps; try { ps = await api.get('/api/projects'); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } if (!ps.length) { listEl.innerHTML = '
Noch keine Projekte. Oben einen Ordner anlegen.
'; return; } listEl.innerHTML = ps.map(p => `
📁
${escapeHtml(p.name)}
${p.pins} Aufnahme${p.pins === 1 ? '' : 'n'} · ${p.tabs} Unterreiter
`).join(''); listEl.querySelectorAll('.proj-card').forEach(c => c.onclick = (e) => { if (e.target.closest('[data-ren]') || e.target.closest('[data-del]')) return; state.projectId = +c.dataset.open; state.projectTabId = 0; viewProjects(); }); listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => { e.stopPropagation(); const p = ps.find(x => x.id == b.dataset.ren); const name = prompt('Ordner umbenennen:', p ? p.name : ''); if (name === null || !name.trim()) return; await api.patch('/api/projects/' + b.dataset.ren, { name: name.trim() }); renderProjectList(v); }); listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { e.stopPropagation(); const p = ps.find(x => x.id == b.dataset.del); if (!confirm(`Ordner „${p ? p.name : ''}" löschen?\nUnterreiter und Anheftungen verschwinden mit. Die Aufnahmen selbst bleiben erhalten.`)) return; await api.del('/api/projects/' + b.dataset.del); toast('Ordner gelöscht'); renderProjectList(v); }); } async function renderProjectDetail(v) { v.innerHTML = '
Lade…
'; let p; try { p = await api.get('/api/projects/' + state.projectId); } catch (e) { v.innerHTML = '
' + e.message + '
'; return; } if (!p) { state.projectId = null; return viewProjects(); } // Der zuletzt gewählte Unterreiter kann gelöscht worden sein → zurück auf die // Ordner-Ebene, statt eine leere Geisterebene anzuzeigen. if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0; const cur = state.projectTabId; const pins = p.pins.filter(x => x.tab_id === cur); // Die Ordner-Ebene ist umbenennbar; ohne eigenen Namen heißt sie „Ordner". const rootName = p.root_tab_name || 'Ordner'; const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : rootName; v.innerHTML = `

📁 ${escapeHtml(p.name)}

${p.tabs.map(t => ``).join('')}
${escapeHtml(levelName)} ${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'}${p.tabs.length && pins.length ? ' · auf einen Reiter ziehen zum Verschieben' : ''} ${cur ? '' : ''}
${pins.length ? pins.map(r => `
${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
${r.summary ? `
${escapeHtml(r.summary)}
` : ''}
`).join('') : '
Hier ist noch nichts angeheftet. Rechts oben eine Aufnahme anheften.
'}
`; v.querySelector('#pback').onclick = () => { state.projectId = null; state.projectTabId = 0; viewProjects(); }; v.querySelector('#prename').onclick = async () => { const name = prompt('Ordner umbenennen:', p.name); if (name === null || !name.trim()) return; await api.patch('/api/projects/' + p.id, { name: name.trim() }); renderProjectDetail(v); }; v.querySelectorAll('[data-ptab]').forEach(b => b.onclick = () => { state.projectTabId = +b.dataset.ptab; renderProjectDetail(v); }); v.querySelector('#ptabnew').onclick = async () => { const name = prompt('Name des Unterreiters:', ''); if (name === null || !name.trim()) return; const t = await api.post(`/api/projects/${p.id}/tabs`, { name: name.trim() }); state.projectTabId = t.id; renderProjectDetail(v); }; const renBtn = v.querySelector('#ptabren'); if (renBtn) renBtn.onclick = async () => { // Auf der Ordner-Ebene ist ein leeres Feld erlaubt: es setzt die // Beschriftung auf die Vorgabe „Ordner" zurück. const name = prompt(cur ? 'Unterreiter umbenennen:' : 'Ordner-Ebene umbenennen (leer = „Ordner"):', levelName); if (name === null || (cur && !name.trim())) return; await api.patch(`/api/projects/${p.id}/tabs/${cur}`, { name: name.trim() }); renderProjectDetail(v); }; const delBtn = v.querySelector('#ptabdel'); if (delBtn) delBtn.onclick = async () => { if (!confirm(`Unterreiter „${levelName}" löschen?\nDie dort angehefteten Aufnahmen werden nur gelöst, nicht gelöscht.`)) return; await api.del(`/api/projects/${p.id}/tabs/${cur}`); state.projectTabId = 0; renderProjectDetail(v); }; v.querySelector('#ppin').onclick = () => pinPickRecordingDialog(p.id, cur, () => renderProjectDetail(v)); // Klick auf eine angeheftete Aufnahme → direkt in die Aufnahmen-Ansicht. v.querySelectorAll('#ppins .item.rec').forEach(it => it.onclick = (e) => { if (e.target.closest('[data-unpin]')) return; jumpToRecording(+it.dataset.open); }); v.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async (e) => { e.stopPropagation(); await api.del(`/api/projects/${p.id}/pins/${b.dataset.unpin}`); toast('Anheftung gelöst'); renderProjectDetail(v); }); bindPinDrag(v, p, cur, rootName); } // Drag&Drop innerhalb eines Ordners: eine angeheftete Aufnahme auf einen // anderen Reiter ziehen verschiebt den Pin dorthin. Verschoben wird nur die // Referenz — die Aufnahme selbst bleibt, wo sie ist. function bindPinDrag(v, p, cur, rootName) { let dragPin = null; const clearMarks = () => v.querySelectorAll('.ptab.drop').forEach(t => t.classList.remove('drop')); v.querySelectorAll('#ppins .item.rec').forEach(it => { it.ondragstart = (e) => { dragPin = it.dataset.pin; it.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; // Ohne gesetzte Daten startet Firefox den Drag gar nicht erst. e.dataTransfer.setData('text/plain', it.dataset.pin); }; it.ondragend = () => { dragPin = null; it.classList.remove('dragging'); clearMarks(); }; }); v.querySelectorAll('.ptab[data-ptab]').forEach(t => { const target = +t.dataset.ptab; t.ondragover = (e) => { if (dragPin == null || target === cur) return; // eigener Reiter = kein Ziel e.preventDefault(); e.dataTransfer.dropEffect = 'move'; t.classList.add('drop'); }; t.ondragleave = () => t.classList.remove('drop'); t.ondrop = async (e) => { e.preventDefault(); clearMarks(); const pinId = dragPin || e.dataTransfer.getData('text/plain'); dragPin = null; if (!pinId || target === cur) return; const name = target ? (p.tabs.find(x => x.id === target) || {}).name : rootName; try { const r = await api.patch(`/api/projects/${p.id}/pins/${pinId}`, { tab_id: target }); toast(r.merged ? `Hängt dort bereits — Anheftung zusammengeführt` : `Verschoben nach „${name}"`); } catch (err) { toast(err.message, true); } renderProjectDetail(v); }; }); } // Aus einer Projekt-Ebene heraus: Aufnahme per Dropdown wählen und anheften. // Das Filterfeld schreibt die Optionsliste neu — bei dreistelligen Aufnahme- // zahlen ist Scrollen im Select sonst unbrauchbar. async function pinPickRecordingDialog(projectId, tabId, done) { let recs; try { recs = await api.get('/api/recordings'); } catch (e) { return toast(e.message, true); } if (!recs.length) return toast('Es gibt noch keine Aufnahmen.', true); const opt = r => ``; openModal('Aufnahme anheften', ` `, { okText: '📌 Anheften', onReady: (root) => { const sel = root.querySelector('#prec'), f = root.querySelector('#pfilter'); f.oninput = () => { const q = f.value.trim().toLowerCase(); const hits = recs.filter(r => !q || (r.title || ('Aufnahme #' + r.id)).toLowerCase().includes(q)); sel.innerHTML = hits.map(opt).join(''); }; f.focus(); }, onOk: async (root) => { const id = +root.querySelector('#prec').value; if (!id) { toast('Bitte eine Aufnahme wählen', true); return false; } await api.post(`/api/projects/${projectId}/pins`, { recording_id: id, tab_id: tabId }); toast('Angeheftet ✓'); if (done) done(); } }); } // Aus der Aufnahmenliste heraus: Ziel (Ordner + Ebene) wählen. Zeigt zugleich // alle bestehenden Anheftungen dieser Aufnahme, damit man sie hier auch wieder // lösen kann. async function pinToProjectDialog(rec, done) { let projects, pins; try { [projects, pins] = await Promise.all([api.get('/api/projects'), api.get(`/api/recordings/${rec.id}/pins`)]); } catch (e) { return toast(e.message, true); } const title = rec.title || 'Aufnahme #' + rec.id; const pinsHtml = () => pins.length ? `
${pins.map(p => `
📁 ${escapeHtml(p.project_name)}${p.tab_name ? ' › ' + escapeHtml(p.tab_name) : ''}
`).join('')}
` : ''; const m = openModal(`„${escapeHtml(title)}" anheften`, `
${pinsHtml()}
`, { okText: '📌 Anheften', onReady: (root) => { const pp = root.querySelector('#pp'), pt = root.querySelector('#pt'); // Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen. const loadTabs = async () => { if (pp.value === 'new') { pt.innerHTML = ''; return; } const p = await api.get('/api/projects/' + pp.value); pt.innerHTML = `` + p.tabs.map(t => ``).join(''); }; pp.onchange = async () => { if (pp.value === 'new') { const name = prompt('Name des neuen Projektordners:', ''); if (name && name.trim()) { const np = await api.post('/api/projects', { name: name.trim() }); pp.insertAdjacentHTML('afterbegin', ``); pp.value = String(np.id); } else { pp.value = projects.length ? String(projects[0].id) : 'new'; } } loadTabs(); }; bindUnpin(root); if (projects.length) loadTabs(); }, onOk: async (root) => { const pid = root.querySelector('#pp').value; if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; } await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value }); toast('Angeheftet ✓'); if (done) done(); } }); function bindUnpin(root) { root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => { await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`); pins = pins.filter(p => p.pin_id != b.dataset.unpin); root.querySelector('#pcur').innerHTML = pinsHtml(); bindUnpin(root); toast('Anheftung gelöst'); if (done) done(); }); } return m; } // Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der // Suche, nur ohne Zeitsprung). function jumpToRecording(id) { state.tab = 'recordings'; state.selectedId = id; state.trashOpen = false; render(); } // ---------- Recordings ---------- // Analysis-duration counter. Shows the total wall time of a processing run // (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at), // frozen to analysis_ms once done. Returns the inner only; callers add the ⏱. function anaTimeSpan(r) { if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`; if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing')) return `${fmt(Date.now() - r.analysis_started_at)}`; return ''; } function ensureAnaTicker() { if (state.anaTicker) return; state.anaTicker = setInterval(() => { document.querySelectorAll('.ana-time.live').forEach(el => { const t0 = +el.dataset.anaStart; if (t0) el.textContent = fmt(Date.now() - t0); }); }, 1000); } // Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the // selected recording's detail (with its own audio player). Crucially, the // while-analysing auto-refresh only rebuilds the LEFT list — it never touches // the RIGHT detail pane, so you can keep listening to an old recording while a // new one is being transcribed/analysed (the old bug: the whole view — detail + // audio element — was re-rendered every 5s, stopping playback). function viewRecordings() { const v = document.querySelector('#view'); // Entering the tab: show only the newest day expanded, the rest collapsed // (applied in refreshRecList once the day groups are known). state.resetCollapse = true; v.innerHTML = `
← Aufnahme wählen
`; v.querySelector('#trashtoggle').onclick = () => { state.trashOpen = !state.trashOpen; state.lastListSig = null; v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen); refreshRecList(); }; // Draggable splitter: list width between LIST_MIN (the old fixed width) and // twice that; persisted for all sessions. const LIST_MIN = 340, LIST_MAX = 680; const listPane = v.querySelector('.rec-list-pane'); const applyW = (w) => { listPane.style.flex = `0 0 ${Math.min(LIST_MAX, Math.max(LIST_MIN, w))}px`; }; applyW(+localStorage.getItem('aime-list-width') || LIST_MIN); v.querySelector('.rec-splitter').onmousedown = (e) => { e.preventDefault(); const startX = e.clientX, startW = listPane.getBoundingClientRect().width; document.body.classList.add('resizing'); const move = (ev) => applyW(startW + (ev.clientX - startX)); const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); document.body.classList.remove('resizing'); localStorage.setItem('aime-list-width', Math.round(listPane.getBoundingClientRect().width)); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }; refreshRecList(); if (state.selectedId != null) openRecording(state.selectedId); } // (Re)build ONLY the left list pane. Called on entry and by the auto-refresh // timer; safe to run while the right pane holds a playing recording. async function refreshRecList() { const listEl = document.querySelector('#reclist'); if (!listEl) return; // left the tab if (state.trashOpen) return refreshTrashList(listEl); let recs, pinCounts = {}; try { recs = await api.get('/api/recordings'); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } // Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze // Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert. try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; } // Reschedule the poll ONLY while something is still processing. const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing'); const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); }; try { if (!recs.length && !state.recording) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; } // Signature of everything the list RENDERS except the live ⏱ seconds (those // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed, // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept. // This is the real fix for "I get pulled away from my recording every 5s": // the 5s poll now no-ops unless a status/title/todo actually changed. const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}|${pinCounts[r.id] || 0}`).join('~'); if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; } state.lastListSig = sig; // Preserve the left pane's scroll position across the rebuild. const scroller = listEl.closest('.rec-list-pane') || listEl; const savedTop = scroller.scrollTop; // Group by calendar day (recs come newest-first). Each day is a collapsible // section; the day header shows the recording count. const groups = []; const byDay = {}; for (const r of recs) { const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); if (!byDay[day]) { byDay[day] = []; groups.push(day); } byDay[day].push(r); } // Fresh tab entry: only the newest day (first group, recs are newest-first) // stays open; manual toggles afterwards are respected until the next entry. if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; } // Live placeholder while a recording runs: its own card at the very top. const liveHtml = state.recording ? `
Aufnahme läuft…
wird nach Stop transkribiert & analysiert
${markerChipsHtml()}
${fmt(Date.now() - state.recStart)}
` : ''; listEl.innerHTML = liveHtml + groups.map(day => { const collapsed = state.collapsedDates.has(day); const items = byDay[day].map(r => { const at = anaTimeSpan(r); return `
${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}
${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}
${r.summary ? `
${escapeHtml(r.summary)}
` : ''}
`; }).join(''); return `
▾ ${escapeHtml(day)} ${byDay[day].length}
${items}
`; }).join(''); // Click a row (not one of its buttons) → open in the right pane. listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => { if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return; openRecording(+it.dataset.open); }); listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => { e.stopPropagation(); const rec = recs.find(r => r.id == b.dataset.pin); pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); }); }); listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => { e.stopPropagation(); const rec = recs.find(r => r.id == b.dataset.ren); await renameRecording(+b.dataset.ren, (rec && rec.title) || ''); }); listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { e.stopPropagation(); const rec = recs.find(r => r.id == b.dataset.del); const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del; if (!confirm(`${name} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) return; await api.del('/api/recordings/' + b.dataset.del); if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '
← Aufnahme wählen
'; } toast('In den Papierkorb verschoben'); state.lastListSig = null; refreshRecList(); }); bindMarkerChips(); listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => { const day = h.dataset.day; if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day); h.closest('.day-group').classList.toggle('collapsed'); }); scroller.scrollTop = savedTop; // keep the list where the user had it ensureAnaTicker(); scheduleNext(); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } } // Rename a recording's title. An empty answer clears the title and hands // naming back to the LLM on the next analysis (see db.renameRecording). async function renameRecording(id, current) { const t = prompt('Titel der Aufnahme:', current || ''); if (t === null) return false; // cancelled try { const r = await api.patch(`/api/recordings/${id}/title`, { title: t }); state.lastListSig = null; refreshRecList(); // Keep the open detail pane in sync without rebuilding it (audio keeps playing). const h = document.querySelector('#detail h2'); if (h && state.selectedId == id) { const badge = h.querySelector('.badge'); h.textContent = r.title || 'Aufnahme #' + id; if (badge) h.appendChild(badge); } toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt'); return true; } catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; } } // Trash pane: same list slot, different contents. Deliberately flat (no day // grouping) — the interesting order here is "when did I delete it", and the // remaining days matter more than the recording date. async function refreshTrashList(listEl) { let data; try { data = await api.get('/api/trash'); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } const items = data.items || []; if (!items.length) { listEl.innerHTML = `
Papierkorb ist leer.
Gelöschte Aufnahmen bleiben ${data.days} Tage hier.
`; return; } listEl.innerHTML = `
Wird nach ${data.days} Tagen automatisch endgültig gelöscht.
` + items.map(r => `
${escapeHtml(r.title) || 'Aufnahme #' + r.id}
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)} · noch ${r.days_left} Tag${r.days_left === 1 ? '' : 'e'}
`).join(''); listEl.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => { await api.post(`/api/trash/${b.dataset.restore}/restore`, {}); toast('Wiederhergestellt'); refreshTrashList(listEl); }); listEl.querySelectorAll('[data-purge]').forEach(b => b.onclick = async () => { const rec = items.find(r => r.id == b.dataset.purge); const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.purge; if (!confirm(`${name} ENDGÜLTIG löschen?\nAudio, Transkript und Analyse werden unwiderruflich entfernt. Das lässt sich nicht rückgängig machen.`)) return; await api.del('/api/trash/' + b.dataset.purge); toast('Endgültig gelöscht'); refreshTrashList(listEl); }); } async function openRecording(id) { state.selectedId = id; const detail = document.querySelector('#detail'); if (!detail) { state.tab = 'recordings'; render(); return setTimeout(() => openRecording(id), 100); } // Highlight the selected row without rebuilding the list (keeps it stable). document.querySelectorAll('#reclist .item.rec').forEach(it => it.classList.toggle('sel', it.dataset.open == id)); detail.innerHTML = '
Lade…
'; const r = await api.get('/api/recordings/' + id); const pending = r.analysis_status === 'pending'; const errored = r.analysis_status === 'error'; const transcribing = r.status === 'transcribing'; const transcribeFailed = r.status === 'error'; const scls = speakerClasses(r.words || []); // rawSpeaker -> sp0..sp3 const spkOrder = Object.keys(scls); // first-appearance order const names = r.speaker_names || {}; // rawSpeaker -> name const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1)); // Speaker segments + turns for the lane visualizer and the live-text box. const segsBySpk = speakerSegments(r.words || []); const turns = wordTurns(r.words || []); const durMs = r.duration_ms || (r.words && r.words.length ? r.words[r.words.length - 1].t_end_ms : 0) || 1; // Draggable module blocks; the saved order wins, unknown/new modules append. // NOT modules: the transcript (own sticky right column) and the speaker-lane // visualizer (fixed full-width strip under the player, spanning both columns). const MOD_TITLES = { analyse: 'Zusammenfassung', todos: 'ToDos', transcript: 'Transkript' }; const vizHtml = spkOrder.length ? `
${spkOrder.map((sp, i) => `
${escapeHtml(spkLabel(sp, i))}
${(segsBySpk[sp] || []).map(s => ``).join('')}
`).join('')}
` : ''; // Live-typed name markers: which cluster each one was resolved to, plus the // option to add one while listening back (same resolution path) and to re-run // the assignment after a re-diarization. const CONF_LBL = { high: 'sicher', mid: 'wahrscheinlich', low: 'unsicher' }; const markersHtml = `

Namensmarker (während der Aufnahme getippt)

${(r.markers || []).length ? `` : '
Keine Marker. Tipp: während der Aufnahme Namen eintippen (Feld oben) – oder hier beim Zuhören nachtragen.
'}
${(r.markers || []).length ? '' : ''}
`; const mods = { analyse: ` ${r.summary ? `

${renderSummary(r.summary)}

` : '
Noch keine Zusammenfassung.
'} ${(r.participants && r.participants.length) ? `

Teilnehmer

` : ''} ${markersHtml}`, todos: (r.todos && r.todos.length) ? `` : '
Keine ToDos erkannt.
', transcript: `
${r.transcript_clean ? `

Dialog (KI · Sprecher aus Kontext)

${renderDialog(r.transcript_clean)}
` : ''}
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
${renderTurns(r.words, names, scls, spkOrder)}
`, }; // Two drop columns; every module (incl. the transcript/table panel) can be // dragged within AND between them. Layout persists for all recordings. const defLayout = { main: ['analyse', 'todos'], side: ['transcript'] }; let layout = null; try { layout = JSON.parse(localStorage.getItem('aime-detail-layout') || 'null'); } catch {} if (!layout || !Array.isArray(layout.main) || !Array.isArray(layout.side)) { // migrate the old single-column order if present let old = []; try { old = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {} layout = { main: old.filter(m => m !== 'transcript'), side: [] }; } layout.main = layout.main.filter(m => mods[m] !== undefined); layout.side = layout.side.filter(m => mods[m] !== undefined && !layout.main.includes(m)); for (const m of [...defLayout.main, ...defLayout.side]) if (!layout.main.includes(m) && !layout.side.includes(m)) (defLayout.side.includes(m) ? layout.side : layout.main).push(m); const modHtml = (m) => `
⠿

${MOD_TITLES[m]}

${mods[m]}
`; detail.innerHTML = `

${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}

${vizHtml}
${anaTimeSpan(r) ? `
⏱ Analysezeit: ${anaTimeSpan(r)}
` : ''} ${transcribing ? '
📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).
' : ''} ${transcribeFailed ? `
Transkription fehlgeschlagen.
` : ''} ${(pending && !transcribing && !transcribeFailed) ? '
🧠 KI-Analyse läuft… (NPU)
' : ''} ${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''} ${(!pending && !errored && !transcribing && !transcribeFailed) ? `` : ''} ${spkOrder.length ? `` : ''}
${layout.main.map(modHtml).join('')}
${layout.side.map(modHtml).join('')}
`; // Drag & drop across BOTH columns: grab any module by its header, drop it in // either column at any position; the layout is saved for all recordings. const cols = [...detail.querySelectorAll('.mod-col')]; // ":empty" never matches because of template whitespace -> class-driven. const markEmpty = () => cols.forEach(c => c.classList.toggle('empty', !c.querySelector('.module'))); markEmpty(); let dragged = null; detail.querySelectorAll('.mod-head').forEach(h => { h.addEventListener('dragstart', (e) => { dragged = h.closest('.module'); dragged.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragged.dataset.mod); // Firefox needs data }); h.addEventListener('dragend', () => { if (!dragged) return; dragged.classList.remove('dragging'); dragged = null; const save = {}; for (const c of cols) save[c.dataset.col] = [...c.querySelectorAll('.module')].map(m => m.dataset.mod); localStorage.setItem('aime-detail-layout', JSON.stringify(save)); markEmpty(); }); }); cols.forEach(col => col.addEventListener('dragover', (e) => { if (!dragged) return; e.preventDefault(); const others = [...col.querySelectorAll('.module:not(.dragging)')]; const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2); if (next) col.insertBefore(dragged, next); else col.appendChild(dragged); markEmpty(); })); // ---- Kontextnotiz -------------------------------------------------------- // EIN Freitextfeld, absichtlich kein Formular mit Teilnehmer-/Begriffsfeldern: // die Box benutzen auch Leute, die kein Formular ausfüllen; Namen und Begriffe // holt sich die Analyse selbst aus der Prosa (llm.js contextBlock). Der // Platzhalter führt statt Pflichtfeldern. Direkt nach dem Stop steht die Box // offen (state.ctxPromptFor). Sie zeichnet sich SELBST neu statt über // openRecording() — ein Neuaufbau der Detailansicht setzt den