Spaces:
Running
Running
| /* ============================================================ | |
| VoiceVault β Frontend Application | |
| ============================================================ */ | |
| ; | |
| // ββ 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, '&') | |
| .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 => | |
| `<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; | |
| } | |