Spaces:
Running
Running
| /* ---------- Config ---------- */ | |
| const API_CORE = "https://thevera-backendvera.hf.space"; | |
| const API_DB = "https://thevera-botveradb.hf.space"; | |
| /* ---------- Elements ---------- */ | |
| const chatBody = document.getElementById("chatBody"); | |
| const userInput = document.getElementById("userInput"); | |
| const sendButton = document.getElementById("sendButton"); | |
| const micButton = document.getElementById("micButton"); | |
| const categorySelect = document.getElementById("categorySelect"); | |
| const speakToggle = document.getElementById("speakToggle"); | |
| const voiceSelect = document.getElementById("voiceSelect"); | |
| const settingsModal = document.getElementById("settingsModal"); | |
| const ttsLang = document.getElementById("ttsLang"); | |
| const ttsRate = document.getElementById("ttsRate"); | |
| const ttsPitch = document.getElementById("ttsPitch"); | |
| const attachButton = document.getElementById("attachButton"); | |
| const healthImage = document.getElementById("healthImage"); | |
| attachButton?.addEventListener("click", ()=> healthImage?.click()); | |
| healthImage?.addEventListener("change", ()=>{ | |
| const f = healthImage.files?.[0]; | |
| if (f) toast(`Attached: ${f.name}`); | |
| }); | |
| /* Mobile sidebar controls */ | |
| const openSidebarBtn = document.getElementById("openSidebar"); | |
| const closeSidebarBtn = document.getElementById("closeSidebar"); | |
| const sidebar = document.getElementById("sidebar"); | |
| const scrim = document.getElementById("scrim"); | |
| /* Header drawer controls (mobile) */ | |
| const openHeaderDrawer = document.getElementById("openHeaderDrawer"); | |
| const closeHeaderDrawer = document.getElementById("closeHeaderDrawer"); | |
| const headerDrawer = document.getElementById("headerDrawer"); | |
| const drawerScrim = document.getElementById("drawerScrim"); | |
| const headerControls = document.getElementById("headerControls"); | |
| const drawerControls = document.getElementById("drawerControls"); | |
| /* Theme toggle */ | |
| const themeToggle = document.getElementById("themeToggle"); | |
| /* Username pill */ | |
| const userPill = document.getElementById("userPill"); | |
| /* ---------- Toasts ---------- */ | |
| function toast(msg){ | |
| const host = document.getElementById("toasts"); | |
| const el = document.createElement("div"); | |
| el.className = "toast"; el.textContent = msg; | |
| host.appendChild(el); | |
| setTimeout(()=> host.contains(el) && host.removeChild(el), 3000); | |
| } | |
| /* ---------- Sidebar toggle (mobile) ---------- */ | |
| function openSidebar(){ sidebar.classList.add("open"); scrim.classList.add("show"); } | |
| function closeSidebar(){ sidebar.classList.remove("open"); scrim.classList.remove("show"); } | |
| openSidebarBtn?.addEventListener("click", openSidebar); | |
| closeSidebarBtn?.addEventListener("click", closeSidebar); | |
| scrim?.addEventListener("click", closeSidebar); | |
| /* ---------- Header drawer (mobile) ---------- */ | |
| const headerControlsEl = document.querySelector(".header-controls"); // safer than getElementById | |
| function mountDrawerControls(){ | |
| if (!drawerControls) return; | |
| // Build a SAFE mirror (no duplicate IDs) | |
| drawerControls.innerHTML = ` | |
| <div class="field"> | |
| <label>Category</label> | |
| <select id="drawerCategory"> | |
| <option value="auto">Auto detect</option> | |
| <option value="spiritual_guidance">Spiritual</option> | |
| <option value="health_wellness">Health</option> | |
| <option value="generate_image">Generate Image</option> | |
| <option value="realtime_query">Realtime</option> | |
| <option value="other_query">Other</option> | |
| </select> | |
| </div> | |
| <button class="icon-btn" type="button" id="drawerOpenSettings">βοΈ Settings</button> | |
| `; | |
| // Mirror value both ways | |
| const drawerCat = document.getElementById("drawerCategory"); | |
| if (drawerCat && categorySelect) { | |
| drawerCat.value = categorySelect.value || "auto"; | |
| drawerCat.onchange = () => { categorySelect.value = drawerCat.value; }; | |
| categorySelect.onchange = () => { drawerCat.value = categorySelect.value; }; | |
| } | |
| const drawerBtn = document.getElementById("drawerOpenSettings"); | |
| drawerBtn && (drawerBtn.onclick = () => settingsModal.showModal()); | |
| } | |
| mountDrawerControls(); | |
| window.addEventListener("resize", mountDrawerControls); | |
| function openDrawer(){ | |
| headerDrawer.classList.add("open"); | |
| drawerScrim.classList.add("show"); | |
| headerDrawer.setAttribute("aria-hidden","false"); | |
| } | |
| function closeDrawer(){ | |
| headerDrawer.classList.remove("open"); | |
| drawerScrim.classList.remove("show"); | |
| headerDrawer.setAttribute("aria-hidden","true"); | |
| } | |
| openHeaderDrawer?.addEventListener("click", openDrawer); | |
| closeHeaderDrawer?.addEventListener("click", closeDrawer); | |
| drawerScrim?.addEventListener("click", closeDrawer); | |
| // ---------- Settings persistence ---------- | |
| const SETTINGS_KEY = "vera_settings_v1"; | |
| const defaultSettings = { | |
| ttsLang: "auto", | |
| voiceName: "", // store by voice.name (more stable than index) | |
| alwaysSpeak: true, | |
| rate: 0.95, | |
| pitch: 0.95, | |
| theme: "saffron", | |
| imgVision: false | |
| }; | |
| function loadSettings(){ | |
| // migrate old theme if present | |
| const legacyTheme = localStorage.getItem("vera_theme"); | |
| let s = {}; | |
| try { s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"); } catch {} | |
| s = { ...defaultSettings, ...s }; | |
| if (legacyTheme && (legacyTheme === "dark" || legacyTheme === "saffron")) { | |
| s.theme = legacyTheme; | |
| localStorage.removeItem("vera_theme"); | |
| } | |
| return s; | |
| } | |
| function saveSettings(){ | |
| const s = { | |
| ttsLang: ttsLang?.value || "auto", | |
| voiceName: (voiceSelect && voiceSelect.selectedOptions[0]?.text?.split(" β ")?.[0]) || "", | |
| alwaysSpeak: !!speakToggle?.checked, | |
| rate: Number(ttsRate?.value ?? defaultSettings.rate), | |
| pitch: Number(ttsPitch?.value ?? defaultSettings.pitch), | |
| theme: themeToggle?.checked ? "dark" : "saffron", | |
| imgVision: !!imgVision?.checked | |
| }; | |
| localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); | |
| // apply immediately where needed | |
| document.documentElement.setAttribute("data-theme", s.theme); | |
| if (themeToggle) themeToggle.checked = (s.theme === "dark"); | |
| return s; | |
| } | |
| function applySettings(s){ | |
| if (!s) s = loadSettings(); | |
| // UI controls | |
| if (ttsLang) ttsLang.value = s.ttsLang; | |
| if (speakToggle) speakToggle.checked = !!s.alwaysSpeak; | |
| if (ttsRate) ttsRate.value = String(s.rate); | |
| if (ttsPitch) ttsPitch.value = String(s.pitch); | |
| if (themeToggle) themeToggle.checked = (s.theme === "dark"); | |
| if (imgVision) imgVision.checked = !!s.imgVision; | |
| document.documentElement.setAttribute("data-theme", s.theme); | |
| // Voice selection happens after voices load | |
| if (voiceSelect && s.voiceName) { | |
| const pickByName = () => { | |
| const options = Array.from(voiceSelect.options); | |
| const ix = options.findIndex(o => (o.text || "").split(" β ")[0] === s.voiceName); | |
| if (ix >= 0) voiceSelect.selectedIndex = ix; | |
| }; | |
| // try now & also after voices refresh | |
| pickByName(); | |
| setTimeout(pickByName, 300); | |
| } | |
| } | |
| // Wire control changes to saveSettings() | |
| [ttsLang, voiceSelect, speakToggle, ttsRate, ttsPitch, themeToggle, imgVision] | |
| .filter(Boolean) | |
| .forEach(el => { | |
| const ev = (el.tagName === "SELECT") ? "change" : | |
| (el.type === "checkbox") ? "change" : | |
| (el.type === "range") ? "input" : "change"; | |
| el.addEventListener(ev, saveSettings); | |
| }); | |
| // On boot: load & apply | |
| applySettings(loadSettings()); | |
| // When voices arrive, try to re-select stored voice | |
| if ("speechSynthesis" in window) { | |
| try { | |
| speechSynthesis.addEventListener("voiceschanged", () => { | |
| const s = loadSettings(); | |
| applySettings(s); // re-apply to pick voiceName | |
| }); | |
| } catch {} | |
| } | |
| /* ---------- Theme handling ---------- */ | |
| (function initTheme(){ | |
| const saved = localStorage.getItem("vera_theme") || "saffron"; | |
| document.documentElement.setAttribute("data-theme", saved); | |
| if (themeToggle) themeToggle.checked = (saved === "dark"); | |
| })(); | |
| themeToggle?.addEventListener("change", () => { | |
| const mode = themeToggle.checked ? "dark" : "saffron"; | |
| document.documentElement.setAttribute("data-theme", mode); | |
| localStorage.setItem("vera_theme", mode); | |
| }); | |
| /* ---------- Username from memory/login ---------- */ | |
| (function showUserFromMemory(){ | |
| try{ | |
| let name = localStorage.getItem("username") || localStorage.getItem("userEmail"); | |
| const token = localStorage.getItem("authToken") || localStorage.getItem("token"); | |
| if (token && window.jwt_decode){ | |
| const info = jwt_decode(token); | |
| const email = info?.email || info?.preferred_username || ""; | |
| if (email && !name) name = email.split("@")[0]; | |
| } | |
| if (name && userPill){ | |
| userPill.textContent = name; | |
| userPill.style.display = "inline-block"; | |
| } | |
| }catch(e){ /* ignore */ } | |
| })(); | |
| /* ---------- Chips ---------- */ | |
| document.querySelectorAll(".chip").forEach(chip=>{ | |
| chip.onclick = ()=>{ userInput.value = chip.dataset.chip; sendMessage(); }; | |
| }); | |
| /* ---------- TTS ---------- */ | |
| let availableVoices = []; | |
| function loadVoices(){ | |
| availableVoices = speechSynthesis.getVoices() || []; | |
| if (!voiceSelect) return; | |
| voiceSelect.innerHTML = ""; | |
| availableVoices.forEach((v,i)=>{ | |
| const opt = document.createElement("option"); | |
| opt.value=i; opt.textContent = `${v.name} β ${v.lang}`; | |
| voiceSelect.appendChild(opt); | |
| }); | |
| const idx = availableVoices.findIndex(v=>/sa-IN|hi-IN|en-IN/i.test(v.lang) && /(male|india|google)/i.test(v.name)); | |
| voiceSelect.value = String(idx>=0?idx:0); | |
| } | |
| if ("speechSynthesis" in window){ | |
| try { speechSynthesis.onvoiceschanged = loadVoices; } catch {} | |
| setTimeout(loadVoices, 200); | |
| } | |
| function pickVoiceByLang(langWanted){ | |
| let v = availableVoices.find(v=> (v.lang||"").toLowerCase() === (langWanted||"").toLowerCase()); | |
| if(v) return v; | |
| const base = (langWanted||"").split("-")[0]; | |
| v = availableVoices.find(v=> (v.lang||"").toLowerCase().startsWith((base||"").toLowerCase())); | |
| return v || availableVoices[parseInt(voiceSelect.value||"0",10)] || null; | |
| } | |
| function plain(input) { | |
| // HTML/MD -> readable speech text | |
| const div = document.createElement("div"); | |
| div.innerHTML = input || ""; | |
| let s = (div.textContent || div.innerText || "").trim(); | |
| s = s | |
| // [text](url) -> text | |
| .replace(/\[(.*?)\]\((https?:\/\/[^)]+)\)/g, "$1") | |
| // raw URLs (incl. angle-bracket style) | |
| .replace(/<?https?:\/\/\S+>?|www\.\S+/gi, "") | |
| // emails | |
| .replace(/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g, "") | |
| // lines starting with Source/Link/URL: | |
| .replace(/(?:^|\n)\s*(source|sources|link|url)\s*:\s*.*$/gmi, "") | |
| // bracketed refs like [1], [2] | |
| .replace(/\s*\[\d+\]/g, "") | |
| // markdown/symbol noise | |
| .replace(/[β’*_#[\]<>`~|]/g, " ") | |
| // collapse spaces | |
| .replace(/\s{2,}/g, " ") | |
| .trim(); | |
| // keep TTS snappy | |
| const MAX = 2800; // ~2β3 minutes at normal rate | |
| if (s.length > MAX) s = s.slice(0, MAX) + "β¦"; | |
| return s; | |
| } | |
| let lastBotPlain = ""; | |
| async function speak(text) { | |
| try { | |
| // 1) Early exits & capability checks | |
| if (!speakToggle?.checked) return; | |
| if (!("speechSynthesis" in window)) { | |
| return (typeof toast === "function" ? toast("TTS not supported") : console.warn("TTS not supported")); | |
| } | |
| if (!text || typeof text !== "string") return; | |
| // 2) Stop any current speech (if you have your own stopSpeak, call it; else cancel) | |
| if (typeof stopSpeak === "function") stopSpeak(true); | |
| else window.speechSynthesis.cancel(); | |
| // 3) Wait for voices to be ready (some browsers load them async) | |
| await ensureVoicesReady(); | |
| // 4) Resolve TTS controls safely | |
| const forcedLang = (window.ttsLang && window.ttsLang.value) ? window.ttsLang.value : "auto"; | |
| const rate = clamp(parseFloat(window?.ttsRate?.value ?? "0.95"), 0.1, 10); | |
| const pitch = clamp(parseFloat(window?.ttsPitch?.value ?? "0.95"), 0, 2); | |
| // 5) Split text to chunks by script/lang if function exists; else single chunk | |
| const chunks = (forcedLang === "auto" && typeof splitByScript === "function") | |
| ? splitByScript(text) | |
| : [{ text, lang: forcedLang }]; | |
| // 6) Build utterances queue | |
| const queue = chunks | |
| .filter(ch => ch?.text) // keep valid chunks only | |
| .map((ch) => { | |
| const u = new SpeechSynthesisUtterance(ch.text); | |
| // Pick a voice: use your custom picker if present, else fallback by lang, else default | |
| let v = (typeof pickVoiceByLang === "function") ? pickVoiceByLang(ch.lang) : null; | |
| if (!v && ch.lang && window.speechSynthesis.getVoices) { | |
| v = window.speechSynthesis.getVoices().find(vc => vc.lang?.toLowerCase()?.startsWith((ch.lang || "").toLowerCase())); | |
| } | |
| if (v) u.voice = v; | |
| // Set language (prefer voice.lang if present) | |
| u.lang = v?.lang || ch.lang || undefined; | |
| u.rate = rate; | |
| u.pitch = pitch; | |
| // Optional: smoother prosody on some engines | |
| // u.volume = 1; | |
| // Error handler per chunk | |
| u.onerror = (e) => console.warn("TTS error:", e?.error || e); | |
| return u; | |
| }); | |
| if (!queue.length) return; | |
| // 7) Chain queue: when one ends, speak the next | |
| queue.forEach((u, i) => { | |
| u.onend = () => { | |
| if (i + 1 < queue.length) window.speechSynthesis.speak(queue[i + 1]); | |
| }; | |
| }); | |
| // 8) Start | |
| window.speechSynthesis.speak(queue[0]); | |
| } catch (err) { | |
| if (typeof toast === "function") toast("TTS error: " + (err?.message || err)); | |
| else console.error(err); | |
| } | |
| // --- helpers --- | |
| function clamp(n, min, max) { | |
| n = Number.isFinite(n) ? n : min; | |
| return Math.max(min, Math.min(max, n)); | |
| } | |
| function ensureVoicesReady() { | |
| return new Promise((resolve) => { | |
| const ready = () => { | |
| const voices = window.speechSynthesis.getVoices?.() || []; | |
| if (voices.length) resolve(); | |
| else resolve(); // resolve anyway to avoid hanging; many engines work without explicit voices | |
| }; | |
| // If voices already loaded | |
| if (window.speechSynthesis.getVoices?.().length) return resolve(); | |
| // Safari/Chrome fire this when ready | |
| window.speechSynthesis.onvoiceschanged = () => { | |
| window.speechSynthesis.onvoiceschanged = null; | |
| ready(); | |
| }; | |
| // Fallback timeout in case the event never fires | |
| setTimeout(ready, 300); | |
| }); | |
| } | |
| } | |
| function splitByScript(s){ | |
| const parts=[], re=/(^[\u0900-\u097F\s,|ΰ₯€]+|[^,|ΰ₯€\u0900-\u097F]+|[,|ΰ₯€])/g; | |
| const tokens = s.match(re)||[s]; let cur=null, buf=""; | |
| const langOf = t=>(/[\u0900-\u097F]/.test(t) ? "hi-IN" : "en-IN"); | |
| tokens.forEach(t=>{ | |
| const l = langOf(t); | |
| if(cur && l!==cur){ parts.push({text:buf.trim(), lang:cur}); buf=t; cur=l; } | |
| else { buf+=t; cur=cur||l; } | |
| }); | |
| if(buf.trim()) parts.push({text:buf.trim(), lang:cur}); | |
| return parts; | |
| } | |
| function stopSpeak(silent){ try{ speechSynthesis.cancel(); }catch{} if(!silent){} } | |
| function pauseSpeak(){ try{ speechSynthesis.pause(); }catch{} } | |
| function resumeSpeak(){ try{ speechSynthesis.resume(); }catch{} } | |
| document.getElementById("btnPlay").onclick = ()=> lastBotPlain && speak(lastBotPlain); | |
| document.getElementById("btnPause").onclick = pauseSpeak; | |
| document.getElementById("btnResume").onclick = resumeSpeak; | |
| document.getElementById("btnStop").onclick = ()=> stopSpeak(); | |
| /* ---------- Input ---------- */ | |
| userInput.addEventListener("keydown", (e)=>{ | |
| // Prevent send while composing (Hindi, etc.) | |
| if (e.isComposing) return; | |
| if (e.key === "Enter" && !e.shiftKey){ | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| // Smooth auto-grow | |
| const BASE_H = 52, MAX_H = 200; | |
| function growInput(){ | |
| userInput.style.height = "auto"; | |
| userInput.style.height = Math.min(userInput.scrollHeight, MAX_H) + "px"; | |
| } | |
| userInput.addEventListener("input", growInput); | |
| growInput(); // initial | |
| /* ---------- Mic ---------- */ | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| if (SR){ | |
| const rec = new SR(); | |
| rec.continuous = false; rec.interimResults = false; | |
| const pickLang = () => (ttsLang?.value && ttsLang.value !== "auto") ? ttsLang.value : (navigator.language || "en-IN"); | |
| rec.lang = pickLang(); | |
| micButton.addEventListener("click", ()=>{ try{ | |
| rec.lang = pickLang(); | |
| if(micButton.classList.contains("on")) rec.stop(); else rec.start(); | |
| }catch{} }); | |
| rec.onstart = ()=> micButton.classList.add("on"); | |
| rec.onend = ()=> micButton.classList.remove("on"); | |
| rec.onresult = (e)=>{ | |
| const txt = e.results?.[0]?.[0]?.transcript || ""; | |
| if (txt) { userInput.value = txt; growInput(); sendMessage(); } | |
| }; | |
| rec.onerror = ()=> micButton.classList.remove("on"); | |
| }else{ | |
| micButton.disabled = true; micButton.title = "Not supported"; | |
| } | |
| /* -------------- Read Image --------------*/ | |
| async function ocrHealthImage() { | |
| const f = document.getElementById("healthImage")?.files?.[0]; | |
| if (!f) return ""; | |
| try { | |
| const { data } = await Tesseract.recognize(f, 'eng'); // add 'hin' model if needed | |
| return (data?.text || "").trim(); | |
| } catch (e) { | |
| console.warn("OCR failed:", e); return ""; | |
| } | |
| } | |
| /* ---------- Send message ---------- */ | |
| function scrollToBottom(){ | |
| // ensure it happens after DOM/layout + md render | |
| requestAnimationFrame(()=>{ | |
| chatBody.scrollTop = chatBody.scrollHeight; | |
| requestAnimationFrame(()=> chatBody.scrollTop = chatBody.scrollHeight); | |
| }); | |
| } | |
| function appendMessage(html, role){ | |
| const el = document.createElement("div"); | |
| el.className = (role === "user") ? "user" : "bot"; | |
| if (role === "user") { | |
| // Render user text as plain text (no HTML injection) | |
| el.textContent = html; | |
| } else { | |
| // Sanitize and allow target/rel on links | |
| const clean = DOMPurify.sanitize(html, { ADD_ATTR: ["target", "rel"] }); | |
| el.innerHTML = clean; | |
| // Open links safely in a new tab | |
| el.querySelectorAll('a[href]').forEach(a => { | |
| a.setAttribute('target', '_blank'); | |
| a.setAttribute('rel', 'noopener noreferrer nofollow'); | |
| }); | |
| // Add copy buttons for code blocks | |
| el.querySelectorAll('pre code').forEach(block => { | |
| const actions = document.createElement("div"); | |
| actions.className = "code-actions"; | |
| const btn = document.createElement("button"); | |
| btn.className = "copy-btn"; | |
| btn.type = "button"; | |
| btn.textContent = "Copy"; | |
| btn.addEventListener("click", async () => { | |
| const text = block.innerText || block.textContent || ""; | |
| try { | |
| if (navigator.clipboard?.writeText) { | |
| await navigator.clipboard.writeText(text); | |
| } else { | |
| // Fallback copy | |
| const ta = document.createElement("textarea"); | |
| ta.value = text; | |
| ta.style.position = "fixed"; | |
| ta.style.opacity = "0"; | |
| document.body.appendChild(ta); | |
| ta.select(); | |
| document.execCommand("copy"); | |
| document.body.removeChild(ta); | |
| } | |
| if (typeof toast === "function") toast("Copied."); | |
| } catch (e) { | |
| console.warn("Copy failed:", e); | |
| } | |
| }); | |
| block.parentElement.after(actions); | |
| actions.appendChild(btn); | |
| }); | |
| // Ensure images trigger a follow-up scroll once loaded | |
| el.querySelectorAll("img").forEach(img => { | |
| img.addEventListener("load", scrollToBottom, { once: true }); | |
| }); | |
| } | |
| chatBody.appendChild(el); | |
| scrollToBottom(); // reliable bottom lock (double-RAF inside helper) | |
| return el; | |
| } | |
| function appendPill(text){ | |
| const el = document.createElement("div"); | |
| el.className = "bot"; | |
| el.innerHTML = `<span class="pill">${text}</span>`; | |
| chatBody.appendChild(el); | |
| chatBody.scrollTop = chatBody.scrollHeight; | |
| } | |
| function addChatItem(role, content){ | |
| const c = activeChat(); c.items.push({role, content, ts: Date.now()}); | |
| if(c.title==="New chat" && c.items.length>=2){ | |
| c.title = (c.items.find(x=>x.role==="user")?.content || "Chat").slice(0,40); | |
| } | |
| store.save(state); | |
| } | |
| function withTimeout(ms){ | |
| const ctrl = new AbortController(); | |
| const id = setTimeout(()=> ctrl.abort(new DOMException("Timeout", "AbortError")), ms); | |
| return { signal: ctrl.signal, cancel: ()=> clearTimeout(id) }; | |
| } | |
| async function sendMessage(){ | |
| const message = (userInput.value || "").trim(); | |
| if (!message) return; | |
| // render user bubble | |
| appendMessage(message, "user"); | |
| addChatItem("user", message); | |
| userInput.value = ""; | |
| if (typeof BASE_H !== "undefined") userInput.style.height = BASE_H + "px"; | |
| // loading row | |
| const wrap = document.createElement("div"); | |
| wrap.className = "bot loading"; | |
| wrap.innerHTML = `<span class="spinner"></span><span>Thinkingβ¦</span>`; | |
| chatBody.appendChild(wrap); | |
| if (typeof scrollToBottom === "function") scrollToBottom(); | |
| // build prompt | |
| const cat = categorySelect?.value; | |
| let finalMessage = message; | |
| if (cat && cat !== "auto") finalMessage = `::category=${cat}\n` + message; | |
| // optional OCR for health (client-side, free) | |
| let image_text = ""; | |
| try { | |
| if (imgVision?.checked && cat === "health_wellness" && typeof ocrHealthImage === "function") { | |
| image_text = await ocrHealthImage(); | |
| } | |
| } catch (e) { | |
| console.warn("OCR failed:", e); | |
| } | |
| // request with timeout | |
| const { signal, cancel } = withTimeout(30000); | |
| sendButton.disabled = true; | |
| try { | |
| const res = await fetch(`${API_CORE}/generate_result`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ message: finalMessage, image_text }), | |
| signal | |
| }); | |
| cancel(); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const data = await res.json(); | |
| // remove loader | |
| wrap.remove(); | |
| // parse "category\ncontent" | |
| const raw = data?.response || ""; | |
| const nl = raw.indexOf("\n"); | |
| const category = (nl >= 0 ? raw.slice(0, nl) : "other_query").trim(); | |
| const content = (nl >= 0 ? raw.slice(nl + 1) : raw).trim(); | |
| appendPill(category.replace(/_/g, " ")); | |
| addChatItem("pill", category.replace(/_/g, " ")); | |
| const html = marked.parse(content || "", { breaks: true }); | |
| appendMessage(html, "bot"); | |
| addChatItem("assistant", content); | |
| // TTS (clean text) | |
| lastBotPlain = plain(html); | |
| if (speakToggle?.checked && lastBotPlain) speak(lastBotPlain); | |
| // fire-and-forget analytics/storage | |
| fetch(`${API_DB}/store_anonymous_chat`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ user_data: message, bot_data: raw }) | |
| }).catch(()=>{}); | |
| } catch (err) { | |
| cancel(); | |
| wrap.remove(); | |
| toast(/AbortError|Timeout/i.test(String(err)) ? "Request timed out" : "Failed to connect"); | |
| console.error(err); | |
| } finally { | |
| sendButton.disabled = false; | |
| if (typeof scrollToBottom === "function") scrollToBottom(); | |
| } | |
| } | |
| /* ---------- Settings modal ---------- */ | |
| document.getElementById("openSettings").onclick = ()=> settingsModal.showModal(); | |
| settingsModal.querySelector(".close").onclick = ()=> settingsModal.close(); | |
| /* ---------- Chats & Folders (local, mirrored to server) ---------- */ | |
| const store = { | |
| load(){ | |
| try{ return JSON.parse(localStorage.getItem("vera_store")||"{}"); }catch{ return {}; } | |
| }, | |
| save(obj){ localStorage.setItem("vera_store", JSON.stringify(obj||{})); } | |
| }; | |
| let state = store.load(); | |
| if(!state.folders) state.folders = []; // [{id,name}] | |
| if(!state.chats) state.chats = []; // [{id,title,folderId,created,items:[{role,content,ts}], lastCategory}] | |
| if(!state.activeChatId){ | |
| const id = crypto.randomUUID(); | |
| state.activeChatId = id; | |
| state.chats.push({id,title:"New chat",created:Date.now(),items:[]}); | |
| store.save(state); | |
| } | |
| const folderList = document.getElementById("folderList"); | |
| const chatList = document.getElementById("chatList"); | |
| function renderLists(){ | |
| folderList.innerHTML = ""; | |
| state.folders.forEach(f=>{ | |
| const li = document.createElement("li"); | |
| li.innerHTML = `<div class="left"><span class="folder-dot"></span><span class="title" title="${f.name}">${f.name}</span></div> | |
| <div class="row-btns"> | |
| <button class="icon-btn" title="Rename">β</button> | |
| <button class="icon-btn" title="Delete">π</button> | |
| </div>`; | |
| li.querySelectorAll("button")[0].onclick = ()=>{ | |
| const name = prompt("Folder name:", f.name); | |
| if(name){ f.name = name; store.save(state); renderLists(); } | |
| }; | |
| li.querySelectorAll("button")[1].onclick = ()=>{ | |
| if(confirm("Delete folder? It won't delete chats, just unassign.")){ | |
| state.chats.forEach(c=>{ if(c.folderId===f.id) c.folderId=null; }); | |
| state.folders = state.folders.filter(x=>x.id!==f.id); | |
| store.save(state); renderLists(); | |
| } | |
| }; | |
| folderList.appendChild(li); | |
| }); | |
| chatList.innerHTML = ""; | |
| state.chats | |
| .slice() | |
| .sort((a,b)=>b.created-a.created) | |
| .forEach(c=>{ | |
| const li = document.createElement("li"); | |
| li.style.borderColor = c.id===state.activeChatId ? "var(--brand)" : "var(--border)"; | |
| li.innerHTML = `<div class="left"><span class="title" title="${c.title}">${c.title}</span></div> | |
| <div class="row-btns"> | |
| <button class="icon-btn" title="Move to folder">π</button> | |
| <button class="icon-btn" title="Rename">β</button> | |
| <button class="icon-btn" title="Delete">π</button> | |
| </div>`; | |
| li.onclick = (e)=>{ | |
| if(e.target.tagName==="BUTTON") return; | |
| state.activeChatId = c.id; store.save(state); drawChat(true); | |
| }; | |
| const [btnFolder, btnRename, btnDelete] = li.querySelectorAll("button"); | |
| btnFolder.onclick = ()=>{ | |
| const options = ["(No folder)", ...state.folders.map(f=>f.name)]; | |
| const choice = prompt("Move to folder:\n" + options.map((n,i)=>`${i}. ${n}`).join("\n"), "0"); | |
| const idx = parseInt(choice||"0",10); | |
| if(!isNaN(idx)){ | |
| if(idx===0) c.folderId = null; | |
| else { const f=state.folders[idx-1]; if(f) c.folderId=f.id; } | |
| store.save(state); renderLists(); | |
| } | |
| }; | |
| btnRename.onclick = ()=>{ | |
| const name = prompt("Chat title:", c.title); | |
| if(name){ c.title = name; store.save(state); renderLists(); } | |
| }; | |
| btnDelete.onclick = ()=>{ | |
| if(confirm("Delete this chat?")){ | |
| state.chats = state.chats.filter(x=>x.id!==c.id); | |
| if(state.activeChatId===c.id){ | |
| const id = crypto.randomUUID(); | |
| state.activeChatId = id; | |
| state.chats.push({id,title:"New chat",created:Date.now(),items:[]}); | |
| } | |
| store.save(state); renderLists(); drawChat(true); | |
| } | |
| }; | |
| chatList.appendChild(li); | |
| }); | |
| } | |
| document.getElementById("btnNewChat").onclick = ()=>{ | |
| const id = crypto.randomUUID(); | |
| state.chats.push({id,title:"New chat",created:Date.now(),items:[]}); | |
| state.activeChatId = id; store.save(state); renderLists(); drawChat(true); | |
| }; | |
| document.getElementById("btnNewFolder").onclick = ()=>{ | |
| const name = prompt("Folder name:"); | |
| if(name){ state.folders.push({id:crypto.randomUUID(), name}); store.save(state); renderLists(); } | |
| }; | |
| function activeChat(){ return state.chats.find(c=>c.id===state.activeChatId); } | |
| function drawChat(scrollToEnd=false){ | |
| chatBody.innerHTML = ""; | |
| const c = activeChat(); | |
| if(!c || !c.items.length){ | |
| // show welcome | |
| const sec = document.createElement("section"); | |
| sec.className = "welcome bot"; | |
| sec.innerHTML = `<img src="icon_vera.png" class="bot-icon"/><p><strong>New conversation.</strong> Ask anything.</p>`; | |
| chatBody.appendChild(sec); | |
| return; | |
| } | |
| c.items.forEach(item => { | |
| if(item.role==="pill"){ | |
| const el = document.createElement("div"); | |
| el.className = "bot"; | |
| el.innerHTML = `<span class="pill">${item.content}</span>`; | |
| chatBody.appendChild(el); | |
| return; | |
| } | |
| const el = document.createElement("div"); | |
| el.className = item.role==="user" ? "user" : "bot"; | |
| const html = marked.parse(item.content || "", { breaks:true }); | |
| el.innerHTML = DOMPurify.sanitize(html); | |
| el.querySelectorAll("pre code").forEach(block=>{ | |
| const wrap = document.createElement("div"); | |
| wrap.className = "code-actions"; | |
| const btn = document.createElement("button"); | |
| btn.className = "copy-btn"; btn.textContent = "Copy"; | |
| btn.onclick = ()=> navigator.clipboard.writeText(block.innerText).then(()=> toast("Copied.")); | |
| block.parentElement.after(wrap); wrap.appendChild(btn); | |
| }); | |
| chatBody.appendChild(el); | |
| }); | |
| if(scrollToEnd) chatBody.scrollTop = chatBody.scrollHeight; | |
| } | |
| renderLists(); | |
| drawChat(); |