VoiceVault / static /app.js
NinjainPJs's picture
Initial release: VoiceVault v1.0.0 β€” Voice-First RAG Knowledge Agent
85f900d
/* ============================================================
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: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>',
error: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/>',
info: '<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"/>',
};
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">${icons[type]}</svg>${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 = '<div class="sidebar-kb-empty">No KBs yet</div>';
return;
}
list.innerHTML = state.kbs.map(kb => `
<div class="sidebar-kb-item" onclick="switchView('kbs')">
<div class="kb-dot${kb.is_protected ? ' protected' : ''}"></div>
<span style="overflow:hidden;text-overflow:ellipsis">${escHtml(kb.display_name)}</span>
</div>
`).join('');
}
function renderKBChips() {
const wrap = document.getElementById('kb-chips');
if (!state.kbs.length) {
wrap.innerHTML = '<span class="kb-chips-empty">No knowledge bases β€” create one first</span>';
return;
}
wrap.innerHTML = state.kbs.map(kb => `
<button class="kb-chip${state.selectedKBs.has(kb.kb_name) ? ' selected' : ''}"
onclick="toggleKBChip('${escHtml(kb.kb_name)}')"
title="${escHtml(kb.display_name)}">
${kb.is_protected ? 'πŸ”’ ' : ''}${escHtml(kb.display_name)}
</button>
`).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 => `
<div class="kb-card">
<div class="kb-card-header">
<div>
<div class="kb-card-name">${escHtml(kb.display_name)}</div>
<div class="kb-card-slug">${escHtml(kb.kb_name)}</div>
</div>
<span class="${kb.is_protected ? 'kb-lock-badge' : 'kb-public-badge'}">
${kb.is_protected ? 'πŸ”’ Protected' : 'βœ“ Public'}
</span>
</div>
<div class="kb-card-stats">
<div class="kb-stat">
<div class="kb-stat-value">${kb.doc_count}</div>
<div class="kb-stat-label">Docs</div>
</div>
<div class="kb-stat">
<div class="kb-stat-value">${kb.chunk_count}</div>
<div class="kb-stat-label">Chunks</div>
</div>
</div>
<div class="kb-card-actions">
<button class="kb-action-btn" onclick="openUploadModal('${escHtml(kb.kb_name)}')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/>
</svg>
Upload Docs
</button>
<button class="kb-action-btn danger" onclick="deleteKB('${escHtml(kb.kb_name)}', '${escHtml(kb.display_name)}')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
</svg>
Delete
</button>
</div>
</div>
`).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 => `
<div class="file-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
</svg>
${escHtml(f.name)} <span style="color:var(--text-3);margin-left:auto">${formatBytes(f.size)}</span>
</div>
`).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 => `
<div class="upload-report-item ${r.status}">
<strong>${escHtml(r.filename)}</strong>
β€” ${r.status === 'success'
? `${r.chunk_count} chunks, ${r.page_count} pages`
: (r.status === 'skipped' ? 'Already indexed' : `Error: ${escHtml(r.message)}`)
}
</div>
`).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, '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg> 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'
? `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg>`;
let confidencePill = '';
if (confidence && role === 'assistant') {
const dot = `<div class="confidence-dot ${confidence}"></div>`;
confidencePill = `<div class="confidence-badge">${dot} ${confidence} confidence${model ? ` Β· ${model}` : ''}</div>`;
}
wrap.innerHTML = `
<div class="msg-avatar">${avatarSVG}</div>
<div class="msg-bubble">
<p>${escHtml(content)}</p>
${confidencePill}
</div>
`;
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 = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg>`;
// Format citations as chips
let citationsHtml = '';
if (res.citations && res.citations.length > 0) {
const chips = res.citations.map((c, i) => `
<span class="citation-chip" title="${escHtml(c.excerpt || '')}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
[${i+1}] ${escHtml(c.source_file)} p.${c.page_number}
</span>
`).join('');
citationsHtml = `<div class="citations-inline">${chips}</div>`;
}
// Confidence badge
const conf = res.confidence_level || '';
const confHtml = conf
? `<div class="confidence-badge"><div class="confidence-dot ${conf}"></div>${conf} confidence Β· ${escHtml(res.model_used || '')}</div>`
: '';
wrap.innerHTML = `
<div class="msg-avatar">${avatarSVG}</div>
<div class="msg-bubble">
<p>${formatAnswer(res.answer)}</p>
${citationsHtml}
${confHtml}
</div>
`;
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 = `
<div class="msg-avatar">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/>
</svg>
</div>
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
`;
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 = '<div style="color:var(--text-3);font-size:12px;width:100%;text-align:center">No query data yet</div>';
return;
}
const max = Math.max(...days.map(d => d.count), 1);
container.innerHTML = days.map(d => `
<div class="day-bar-wrap">
<div class="day-bar" style="height:${Math.max((d.count / max) * 90, 3)}px" title="${d.count} queries"></div>
<div class="day-label">${d.date?.slice(5) || ''}</div>
</div>
`).join('');
}
function renderKBInventory(kbs) {
const tbody = document.getElementById('kb-inventory-body');
if (!kbs.length) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-3);text-align:center">No knowledge bases</td></tr>';
return;
}
tbody.innerHTML = kbs.map(kb => `
<tr>
<td>${escHtml(kb.display_name)}</td>
<td style="font-family:var(--mono);font-size:12px">${escHtml(kb.kb_name)}</td>
<td>${kb.doc_count}</td>
<td>${kb.chunk_count}</td>
</tr>
`).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatAnswer(text) {
if (!text) return '';
// Convert citation markers [Source: ...] to styled inline chips
const escaped = escHtml(text);
return escaped.replace(/\[Source:[^\]]+\]/g, match =>
`<span style="font-size:11px;color:var(--accent);font-family:var(--mono)">${match}</span>`
);
}
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
? `<div class="spinner"></div> ${label}`
: label;
}