ras / bot_script.js
bhagvat's picture
Update bot_script.js
d459636 verified
Raw
History Blame Contribute Delete
28.4 kB
/* ---------- 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();