/* ============================================================ VoiceVault — Frontend Application ============================================================ */ 'use strict'; // ── State ───────────────────────────────────────────────────── const state = { kbs: [], selectedKBs: new Set(), history: [], // [{q, a}] lastAnswer: '', lastTtsText: '', currentView: 'ask', uploadTargetKB: null, selectedFiles: [], mediaRecorder: null, audioChunks: [], isRecording: false, isTranscribing: false, isSending: false, }; // ── Init ────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { loadKBs(); }); // ── View Switching ──────────────────────────────────────────── function switchView(name) { document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); document.getElementById(`view-${name}`)?.classList.remove('hidden'); document.querySelectorAll('.nav-item').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === name); }); state.currentView = name; if (name === 'analytics') loadAnalytics(); } // ── API Helpers ─────────────────────────────────────────────── async function api(method, path, body = null, isForm = false) { const opts = { method, headers: {} }; if (body) { if (isForm) { opts.body = body; // FormData — don't set Content-Type } else { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; } } const res = await fetch(`/api${path}`, opts); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || res.statusText); } return res.json(); } // ── Toast Notifications ─────────────────────────────────────── function toast(msg, type = 'info', duration = 4000) { const icons = { success: '', error: '', info: '', }; const el = document.createElement('div'); el.className = `toast ${type}`; el.innerHTML = `${icons[type]}${msg}`; document.getElementById('toasts').appendChild(el); setTimeout(() => el.remove(), duration); } // ── Knowledge Base Loading ──────────────────────────────────── async function loadKBs() { try { state.kbs = await api('GET', '/kbs'); renderSidebarKBs(); renderKBChips(); if (state.currentView === 'kbs') renderKBGrid(); } catch (e) { console.error('Failed to load KBs:', e); } } function renderSidebarKBs() { const list = document.getElementById('sidebar-kb-list'); if (!state.kbs.length) { list.innerHTML = ''; return; } list.innerHTML = state.kbs.map(kb => ` `).join(''); } function renderKBChips() { const wrap = document.getElementById('kb-chips'); if (!state.kbs.length) { wrap.innerHTML = 'No knowledge bases — create one first'; return; } wrap.innerHTML = state.kbs.map(kb => ` `).join(''); } function toggleKBChip(kbName) { if (state.selectedKBs.has(kbName)) state.selectedKBs.delete(kbName); else state.selectedKBs.add(kbName); renderKBChips(); } function renderKBGrid() { const grid = document.getElementById('kb-grid'); const empty = document.getElementById('kb-empty'); if (!state.kbs.length) { grid.innerHTML = ''; grid.appendChild(empty); empty.style.display = 'flex'; return; } grid.innerHTML = state.kbs.map(kb => `
${escHtml(kb.display_name)}
${escHtml(kb.kb_name)}
${kb.is_protected ? '🔒 Protected' : '✓ Public'}
${kb.doc_count}
Docs
${kb.chunk_count}
Chunks
`).join(''); } // ── Create KB ───────────────────────────────────────────────── function openCreateKBModal() { document.getElementById('new-kb-slug').value = ''; document.getElementById('new-kb-name').value = ''; document.getElementById('new-kb-pass').value = ''; openModal('modal-create-kb'); } async function createKB() { const slug = document.getElementById('new-kb-slug').value.trim(); const name = document.getElementById('new-kb-name').value.trim(); const pass = document.getElementById('new-kb-pass').value; if (!slug || !name) { toast('Slug and display name are required.', 'error'); return; } const btn = document.getElementById('create-kb-btn'); setLoading(btn, true, 'Creating…'); try { await api('POST', '/kbs', { kb_name: slug, display_name: name, password: pass || null }); toast(`Knowledge base "${name}" created!`, 'success'); closeModal(); await loadKBs(); if (state.currentView === 'kbs') renderKBGrid(); } catch (e) { toast(e.message, 'error'); } finally { setLoading(btn, false, 'Create Knowledge Base'); } } // Auto-slugify display name → slug field document.addEventListener('DOMContentLoaded', () => { document.getElementById('new-kb-name')?.addEventListener('input', (e) => { const slugEl = document.getElementById('new-kb-slug'); if (!slugEl.dataset.dirty) { slugEl.value = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64); } }); document.getElementById('new-kb-slug')?.addEventListener('input', (e) => { e.target.dataset.dirty = '1'; }); }); function slugifyInput(el) { const pos = el.selectionStart; el.value = el.value.toLowerCase().replace(/[^a-z0-9-]/g, ''); el.setSelectionRange(pos, pos); } // ── Delete KB ───────────────────────────────────────────────── async function deleteKB(kbName, displayName) { if (!confirm(`Delete "${displayName}"?\n\nThis removes all documents, chunks, and query history. This cannot be undone.`)) return; try { await api('DELETE', `/kbs/${encodeURIComponent(kbName)}`); toast(`"${displayName}" deleted.`, 'success'); state.selectedKBs.delete(kbName); await loadKBs(); renderKBGrid(); } catch (e) { toast(e.message, 'error'); } } // ── Upload Documents ────────────────────────────────────────── function openUploadModal(kbName) { state.uploadTargetKB = kbName; state.selectedFiles = []; document.getElementById('upload-kb-name').value = kbName; document.getElementById('upload-pass').value = ''; document.getElementById('file-list').innerHTML = ''; document.getElementById('upload-progress').innerHTML = ''; document.getElementById('upload-progress').classList.remove('show'); openModal('modal-upload'); } function handleFileSelect(event) { state.selectedFiles = [...event.target.files]; renderFileList(); } function handleDrop(event) { event.preventDefault(); document.getElementById('file-drop-zone').classList.remove('drag-over'); state.selectedFiles = [...event.dataTransfer.files]; renderFileList(); } function handleDragOver(event) { event.preventDefault(); document.getElementById('file-drop-zone').classList.add('drag-over'); } function handleDragLeave(event) { document.getElementById('file-drop-zone').classList.remove('drag-over'); } function renderFileList() { const list = document.getElementById('file-list'); list.innerHTML = state.selectedFiles.map(f => `
${escHtml(f.name)} ${formatBytes(f.size)}
`).join(''); } async function uploadDocuments() { if (!state.selectedFiles.length) { toast('Select at least one file.', 'error'); return; } const btn = document.getElementById('upload-btn'); setLoading(btn, true, 'Uploading…'); document.getElementById('upload-progress').classList.remove('show'); document.getElementById('upload-progress').innerHTML = ''; const form = new FormData(); state.selectedFiles.forEach(f => form.append('files', f)); const pass = document.getElementById('upload-pass').value; if (pass) form.append('password', pass); try { const res = await api('POST', `/kbs/${encodeURIComponent(state.uploadTargetKB)}/documents`, form, true); const prog = document.getElementById('upload-progress'); prog.classList.add('show'); prog.innerHTML = res.reports.map(r => `
${escHtml(r.filename)} — ${r.status === 'success' ? `${r.chunk_count} chunks, ${r.page_count} pages` : (r.status === 'skipped' ? 'Already indexed' : `Error: ${escHtml(r.message)}`) }
`).join(''); const succeeded = res.reports.filter(r => r.status === 'success').length; toast(`${succeeded}/${res.reports.length} file(s) indexed.`, succeeded > 0 ? 'success' : 'error'); await loadKBs(); if (state.currentView === 'kbs') renderKBGrid(); } catch (e) { toast(e.message, 'error'); } finally { setLoading(btn, false, ' Upload & Index'); } } // ── Recording ───────────────────────────────────────────────── async function toggleRecording() { if (state.isRecording) { stopRecording(); } else { await startRecording(); } } async function startRecording() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); state.audioChunks = []; // Pick best supported format const mimeType = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg', ''] .find(m => !m || MediaRecorder.isTypeSupported(m)) || ''; const opts = mimeType ? { mimeType } : {}; state.mediaRecorder = new MediaRecorder(stream, opts); state.mediaRecorder.ondataavailable = e => { if (e.data.size > 0) state.audioChunks.push(e.data); }; state.mediaRecorder.onstop = async () => { stream.getTracks().forEach(t => t.stop()); await transcribeRecording(); }; state.mediaRecorder.start(100); state.isRecording = true; setRecordingUI(true); } catch (e) { toast('Microphone access denied. Please allow microphone access in your browser.', 'error'); } } function stopRecording() { if (state.mediaRecorder && state.isRecording) { state.mediaRecorder.stop(); state.isRecording = false; setRecordingUI(false); setTranscribingUI(true); } } async function transcribeRecording() { if (!state.audioChunks.length) { setTranscribingUI(false); return; } try { // Convert any browser audio format (WebM, OGG) → WAV via Web Audio API. // This guarantees the server receives a soundfile-compatible PCM WAV // without needing ffmpeg on the server side. const mimeType = state.mediaRecorder?.mimeType || 'audio/webm'; const rawBlob = new Blob(state.audioChunks, { type: mimeType }); const wavBlob = await convertBlobToWav(rawBlob); const file = new File([wavBlob], 'recording.wav', { type: 'audio/wav' }); const form = new FormData(); form.append('audio', file); const res = await fetch('/api/transcribe', { method: 'POST', body: form }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || 'Transcription failed'); } const data = await res.json(); if (data.transcript) { document.getElementById('query-input').value = data.transcript; autoResize(document.getElementById('query-input')); toast('Transcribed successfully', 'success', 2500); } } catch (e) { toast(`Transcription error: ${e.message}`, 'error'); } finally { setTranscribingUI(false); } } // ── Audio → WAV Conversion (pure browser, no ffmpeg) ───────── async function convertBlobToWav(blob) { const arrayBuffer = await blob.arrayBuffer(); const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); audioCtx.close(); return audioBufferToWavBlob(audioBuffer); } function audioBufferToWavBlob(audioBuffer) { const numChannels = Math.min(audioBuffer.numberOfChannels, 1); // mono const sampleRate = audioBuffer.sampleRate; const numSamples = audioBuffer.length; const bytesPerSample = 2; // 16-bit PCM const blockAlign = numChannels * bytesPerSample; const byteRate = sampleRate * blockAlign; const dataSize = numSamples * blockAlign; const buf = new ArrayBuffer(44 + dataSize); const view = new DataView(buf); // WAV RIFF header _wavStr(view, 0, 'RIFF'); view.setUint32(4, 36 + dataSize, true); _wavStr(view, 8, 'WAVE'); _wavStr(view, 12, 'fmt '); view.setUint32(16, 16, true); // PCM chunk size view.setUint16(20, 1, true); // PCM format view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, byteRate, true); view.setUint16(32, blockAlign, true); view.setUint16(34, 16, true); // bits per sample _wavStr(view, 36, 'data'); view.setUint32(40, dataSize, true); // Write 16-bit signed PCM samples from channel 0 const channelData = audioBuffer.getChannelData(0); let offset = 44; for (let i = 0; i < numSamples; i++) { const s = Math.max(-1, Math.min(1, channelData[i])); view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); offset += 2; } return new Blob([buf], { type: 'audio/wav' }); } function _wavStr(view, offset, str) { for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i)); } function setRecordingUI(recording) { const btn = document.getElementById('mic-btn'); const micIcon = document.getElementById('mic-icon'); const stopIcon = document.getElementById('stop-icon'); const status = document.getElementById('recording-status'); btn.classList.toggle('recording', recording); micIcon.style.display = recording ? 'none' : ''; stopIcon.style.display = recording ? '' : 'none'; status.classList.toggle('show', recording); } function setTranscribingUI(on) { state.isTranscribing = on; document.getElementById('transcribing-status').classList.toggle('show', on); document.getElementById('mic-btn').disabled = on; } // ── Send Query ──────────────────────────────────────────────── async function sendQuery() { const query = document.getElementById('query-input').value.trim(); if (!query) { toast('Type or speak a question first.', 'error'); return; } if (state.selectedKBs.size === 0) { toast('Select at least one Knowledge Base.', 'error'); return; } if (state.isSending) return; state.isSending = true; document.getElementById('send-btn').disabled = true; document.getElementById('query-input').value = ''; autoResize(document.getElementById('query-input')); // Append user message hideChatEmpty(); appendMessage('user', query); const typingEl = appendTypingIndicator(); try { const res = await api('POST', '/ask', { query, kb_names: [...state.selectedKBs], history: state.history.map(h => [h.q, h.a]), }); typingEl.remove(); appendAssistantMessage(res); state.history.push({ q: query, a: res.answer }); state.lastTtsText = res.tts_text || res.answer; } catch (e) { typingEl.remove(); appendMessage('assistant', `⚠️ ${e.message}`, null, null); toast(e.message, 'error'); } finally { state.isSending = false; document.getElementById('send-btn').disabled = false; } } function handleInputKey(event) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendQuery(); } } // ── Chat Rendering ──────────────────────────────────────────── function hideChatEmpty() { const el = document.getElementById('chat-empty'); if (el) el.style.display = 'none'; } function appendMessage(role, content, confidence, model) { const area = document.getElementById('chat-area'); const wrap = document.createElement('div'); wrap.className = `message ${role}`; const avatarSVG = role === 'user' ? `` : ``; let confidencePill = ''; if (confidence && role === 'assistant') { const dot = `
`; confidencePill = `
${dot} ${confidence} confidence${model ? ` · ${model}` : ''}
`; } wrap.innerHTML = `
${avatarSVG}

${escHtml(content)}

${confidencePill}
`; area.appendChild(wrap); area.scrollTop = area.scrollHeight; return wrap; } function appendAssistantMessage(res) { const area = document.getElementById('chat-area'); const wrap = document.createElement('div'); wrap.className = 'message assistant'; const avatarSVG = ``; // Format citations as chips let citationsHtml = ''; if (res.citations && res.citations.length > 0) { const chips = res.citations.map((c, i) => ` [${i+1}] ${escHtml(c.source_file)} p.${c.page_number} `).join(''); citationsHtml = `
${chips}
`; } // Confidence badge const conf = res.confidence_level || ''; const confHtml = conf ? `
${conf} confidence · ${escHtml(res.model_used || '')}
` : ''; wrap.innerHTML = `
${avatarSVG}

${formatAnswer(res.answer)}

${citationsHtml} ${confHtml}
`; area.appendChild(wrap); area.scrollTop = area.scrollHeight; return wrap; } function appendTypingIndicator() { const area = document.getElementById('chat-area'); const wrap = document.createElement('div'); wrap.className = 'message assistant'; wrap.innerHTML = `
`; area.appendChild(wrap); area.scrollTop = area.scrollHeight; return wrap; } function clearChat() { const area = document.getElementById('chat-area'); // Remove all messages but not the empty state area.querySelectorAll('.message').forEach(el => el.remove()); document.getElementById('chat-empty').style.display = ''; state.history = []; state.lastTtsText = ''; } // ── TTS ─────────────────────────────────────────────────────── function speakLastAnswer() { const text = state.lastTtsText; if (!text) { toast('No answer to read yet.', 'info'); return; } if (!('speechSynthesis' in window)) { toast('Text-to-speech not supported in your browser.', 'error'); return; } window.speechSynthesis.cancel(); const utt = new SpeechSynthesisUtterance(text); utt.rate = 1.0; utt.pitch = 1.0; window.speechSynthesis.speak(utt); } // ── Analytics ───────────────────────────────────────────────── async function loadAnalytics() { try { const data = await api('GET', '/analytics'); const s = data.stats; document.getElementById('stat-total-queries').textContent = s.total_queries ?? '0'; document.getElementById('stat-avg-latency').textContent = s.avg_latency_ms ? `${Math.round(s.avg_latency_ms)}` : '0'; document.getElementById('stat-avg-citations').textContent = s.avg_citation_count ?? '0'; document.getElementById('stat-kb-count').textContent = data.kbs.length ?? '0'; renderDayChart(s.queries_by_day || []); renderKBInventory(data.kbs || []); } catch (e) { toast(`Analytics error: ${e.message}`, 'error'); } } function renderDayChart(days) { const container = document.getElementById('day-chart'); if (!days.length) { container.innerHTML = '
No query data yet
'; return; } const max = Math.max(...days.map(d => d.count), 1); container.innerHTML = days.map(d => `
${d.date?.slice(5) || ''}
`).join(''); } function renderKBInventory(kbs) { const tbody = document.getElementById('kb-inventory-body'); if (!kbs.length) { tbody.innerHTML = 'No knowledge bases'; return; } tbody.innerHTML = kbs.map(kb => ` ${escHtml(kb.display_name)} ${escHtml(kb.kb_name)} ${kb.doc_count} ${kb.chunk_count} `).join(''); } // ── Modal Helpers ───────────────────────────────────────────── function openModal(id) { document.getElementById('modal-overlay').classList.remove('hidden'); document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); document.getElementById(id).classList.remove('hidden'); } function closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); } function handleOverlayClick(event) { if (event.target === document.getElementById('modal-overlay')) closeModal(); } // ── Utility ─────────────────────────────────────────────────── function escHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatAnswer(text) { if (!text) return ''; // Convert citation markers [Source: ...] to styled inline chips const escaped = escHtml(text); return escaped.replace(/\[Source:[^\]]+\]/g, match => `${match}` ); } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function autoResize(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; } function setLoading(btn, loading, label) { btn.disabled = loading; btn.innerHTML = loading ? `
${label}` : label; }