/* ============================================================
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 = `${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 => `
`).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;
}