/* Diba chat UI — shared front-end, fully self-hosted (no external CDN).
Backends: "openai" (llama.cpp server, same origin) and "gradio" (ZeroGPU Space, Gradio REST SSE).
Config globals: DIBA_BACKEND, DIBA_ASSET_BASE (prefix for vendor/ assets), DIBA_API_BASE. */
(function () {
"use strict";
if (window.__dibaUI) return; window.__dibaUI = true;
const ASSET = window.DIBA_ASSET_BASE || "";
const VENDOR = {
marked: ASSET + "vendor/marked.min.js",
purify: ASSET + "vendor/purify.min.js",
hljs: ASSET + "vendor/highlight.min.js",
vazir: ASSET + "vendor/fonts/Vazirmatn-Variable.woff2",
monoRegular: ASSET + "vendor/fonts/JetBrainsMono-Regular.woff2",
monoSemi: ASSET + "vendor/fonts/JetBrainsMono-SemiBold.woff2",
pdfjs: ASSET + "vendor/pdf.min.js",
pdfWorker: ASSET + "vendor/pdf.worker.min.js",
};
const T = {
fa: {
dir: "rtl", brandSub: "دستیار هوش مصنوعی دیباچین", newChat: "گفتوگوی جدید", history: "گفتوگوها", noChats: "هنوز گفتوگویی ندارید.",
welcome: "با دیبا شروع کنیم", welcomeSub: "دستیار هوش مصنوعی دیباچین — برای گفتگو، کدنویسی و کارهای روزمرهی شما.",
placeholder: "پیام به دیبا…", disclaimer: "دیبا ممکن است اشتباه کند. اطلاعات مهم را بررسی کنید.", preview: "پیشنمایش",
copy: "کپی", copied: "کپی شد", copyCode: "کپی کد", regenerate: "پاسخ دوباره", delete: "حذف", stop: "توقف", send: "ارسال", menu: "منو",
settings: "تنظیمات", model: "مدل", temperature: "خلاقیت", maxTokens: "حداکثر طول پاسخ", tempHint: "برای کدنویسی و پاسخ دقیق، خلاقیت را روی صفر نگه دارید.",
light: "روشن", dark: "تیره", error: "ارتباط با مدل برقرار نشد. لطفاً دوباره تلاش کنید.", jump: "رفتن به آخرین پیام",
footer: 'ساختهی دیباچین ', untitled: "گفتوگوی بدون عنوان",
coldStart: "در حال آمادهسازی مدل… اولین پاسخ ممکن است چند ثانیه طول بکشد.",
signIn: "ورود با Hugging Face", signOut: "خروج", ownQuota: "برای اجرا با سهمیهی GPU خودتان وارد شوید.",
},
en: {
dir: "ltr", brandSub: "AI assistant by Dibachain", newChat: "New chat", history: "Chats", noChats: "No chats yet.",
welcome: "Let's start with Diba", welcomeSub: "Dibachain's AI assistant — for conversation, coding and your everyday work.",
placeholder: "Message Diba…", disclaimer: "Diba can make mistakes. Check important information.", preview: "Preview",
copy: "Copy", copied: "Copied", copyCode: "Copy code", regenerate: "Regenerate", delete: "Delete", stop: "Stop", send: "Send", menu: "Menu",
settings: "Settings", model: "Model", temperature: "Creativity", maxTokens: "Max response length", tempHint: "Keep creativity at 0 for code and precise answers.",
light: "Light", dark: "Dark", error: "Could not reach the model. Please try again.", jump: "Jump to latest",
footer: 'Built by Dibachain ', untitled: "Untitled chat",
coldStart: "Warming up the model… the first reply may take a few seconds.",
signIn: "Sign in with Hugging Face", signOut: "Sign out", ownQuota: "Sign in to run on your own GPU quota.",
},
};
const SUGG = {
fa: [
{ t: "تاریخ ایران", q: "کوروش بزرگ که بود و چه کارهایی کرد؟" },
{ t: "کدنویسی", q: "یک تابع جاوااسکریپت بنویس که اعداد انگلیسی یک رشته را به فارسی تبدیل کند." },
{ t: "نوشتن", q: "یک ایمیل رسمی و کوتاه برای درخواست یک روز مرخصی بنویس." },
{ t: "دیباچین", q: "دیباچین چه خدماتی ارائه میدهد؟" },
],
en: [
{ t: "Iranian history", q: "Who was Cyrus the Great and what is he known for?" },
{ t: "Coding", q: "Write a Python function that checks whether a string is a palindrome." },
{ t: "Writing", q: "Write a short, polite email asking my manager for a day off tomorrow." },
{ t: "Dibachain", q: "What services does Dibachain offer?" },
],
};
const SYSTEM = {
fa: "تو «دیبا» هستی، دستیار هوش مصنوعی ساختهی شرکت دیباچین. کاربر به فارسی نوشته؛ فقط به فارسی جواب بده، به زبان نوشتاری و مؤدبانه. دقیقاً به همان چیزی که کاربر خواسته جواب بده، روشن و بدون مقدمهی اضافه. از قالببندی Markdown (عنوان، فهرست، جدول، بلوک کد) هر جا به خوانایی کمک میکند استفاده کن. اگر کد لازم است، کد تمیز و کامل در بلوک کد بده. هرگز نام مدل پایه یا سازندهی آن را نگو؛ اگر پرسیدند فقط بگو روی یک مدل زبانی متنباز ساخته شدهای و دیباچین تو را آموزش داده است.",
en: "You are Diba, an AI assistant built by the company Dibachain. The user wrote in English, so reply ONLY in English. Be clear, accurate and professional. Answer exactly what was asked, without unnecessary preamble. Use Markdown formatting (headings, lists, tables, code blocks) where it improves readability. When code is needed, give clean, complete code in a fenced code block. Never name or hint at any base model or its maker; if asked, say only that you were built on an open-source language model and trained by Dibachain.",
other: "You are Diba, an AI assistant built by the company Dibachain. Reply in the same language the user wrote in. Be clear, accurate and professional, and use Markdown where helpful. Never name any base model or its maker.",
};
const ICON = {
clip: ' ',
plus: ' ',
menu: ' ',
send: ' ',
stop: ' ',
mic: ' ',
gear: ' ',
down: ' ',
copy: ' ',
check: ' ',
regen: ' ',
trash: ' ',
};
// ---------------------------------------------------------------- utils
const $ = (sel, el = document) => el.querySelector(sel);
const store = {
get(k, d) { try { const v = localStorage.getItem("diba." + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem("diba." + k, JSON.stringify(v)); } catch (e) { /* storage unavailable */ } },
};
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const loadScript = (src) => new Promise((res, rej) => { const s = document.createElement("script"); s.src = src; s.onload = res; s.onerror = () => rej(new Error("load " + src)); document.head.appendChild(s); });
function injectFonts() {
const css = `@font-face{font-family:"Vazirmatn";src:url("${VENDOR.vazir}") format("woff2");font-weight:100 900;font-style:normal;font-display:swap}` +
`@font-face{font-family:"JetBrains Mono";src:url("${VENDOR.monoRegular}") format("woff2");font-weight:400;font-display:swap}` +
`@font-face{font-family:"JetBrains Mono";src:url("${VENDOR.monoSemi}") format("woff2");font-weight:600;font-display:swap}`;
const st = document.createElement("style"); st.textContent = css; document.head.appendChild(st);
}
function detectLang(text) {
const noCode = text.replace(/```[\s\S]*?```|`[^`]*`/g, " ");
const fa = (noCode.match(/[-ۿ]/g) || []).length;
const en = (noCode.match(/[A-Za-z]/g) || []).length;
if (!fa && !en) return "other";
return fa >= en ? "fa" : "en";
}
const strongDir = (text) => { const m = (text || "").match(/[-ۿA-Za-z]/); return m && /[-ۿ]/.test(m[0]) ? "rtl" : "ltr"; };
// ---------------------------------------------------------------- state
const BACKEND = window.DIBA_BACKEND || (document.querySelector("gradio-app") || window.gradio_config ? "gradio" : "openai");
const S = {
lang: store.get("lang", "fa"),
theme: store.get("theme", window.matchMedia && matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
settings: Object.assign({ temperature: 0, max_tokens: 1024, model: "diba-base" }, store.get("settings", {})),
convs: store.get("convs", []),
currentId: null, streaming: false, controller: null,
sideOpen: window.innerWidth > 820,
};
// Models the backend can serve (injected as window.DIBA_MODELS by the Gradio Space). Anything else: Diba-Base only.
const MODELS = (Array.isArray(window.DIBA_MODELS) && window.DIBA_MODELS.length ? window.DIBA_MODELS : [{ id: "diba-base", label: "Diba-Base", default: true }])
.filter((m) => m && typeof m.id === "string" && m.id).map((m) => ({ id: m.id, label: String(m.label || m.id), default: !!m.default }));
const defaultModel = () => (MODELS.find((m) => m.default) || MODELS[0]).id;
if (!MODELS.some((m) => m.id === S.settings.model)) S.settings.model = defaultModel(); // a remembered model that is no longer offered falls back
const modelLabel = () => (MODELS.find((m) => m.id === S.settings.model) || MODELS[0]).label;
const qp = new URLSearchParams(location.search);
if (qp.get("lang") === "en" || qp.get("lang") === "fa") S.lang = qp.get("lang");
if (qp.get("theme") === "dark" || qp.get("theme") === "light") S.theme = qp.get("theme");
const t = (k) => T[S.lang][k];
const current = () => S.convs.find((c) => c.id === S.currentId);
const saveConvs = () => store.set("convs", S.convs.slice(0, 60));
// ---------------------------------------------------------------- markdown
function renderMarkdown(md) {
if (!window.marked || !window.DOMPurify) {
return md.split(/\n{2,}/).map((p) => `
${esc(p).replace(/\n/g, " ")}
`).join("");
}
const html = window.DOMPurify.sanitize(window.marked.parse(md, { gfm: true, breaks: false }), { ADD_ATTR: ["target"] });
const box = document.createElement("div");
box.innerHTML = html;
box.querySelectorAll("pre > code").forEach((code) => {
const pre = code.parentElement;
const lang = (code.className.match(/language-([\w+#.-]+)/) || [])[1] || "";
const wrap = document.createElement("div"); wrap.className = "d-code";
wrap.innerHTML = `${esc(lang || "code")} ${ICON.copy}${esc(t("copyCode"))}
`;
pre.replaceWith(wrap); wrap.appendChild(pre);
if (window.hljs) {
try {
if (lang && window.hljs.getLanguage(lang)) { code.innerHTML = window.hljs.highlight(code.textContent, { language: lang, ignoreIllegals: true }).value; }
else { code.innerHTML = window.hljs.highlightAuto(code.textContent).value; }
} catch (e) { /* leave plain */ }
}
});
box.querySelectorAll("table").forEach((tb) => { const w = document.createElement("div"); w.className = "d-table"; tb.replaceWith(w); w.appendChild(tb); });
box.querySelectorAll("a").forEach((a) => { a.target = "_blank"; a.rel = "noopener noreferrer"; });
box.querySelectorAll("p, ul, ol, li, h1, h2, h3, h4, h5, h6, blockquote, .d-table").forEach((n) => {
if (n.closest(".d-code")) return;
n.setAttribute("dir", strongDir((n.textContent || "").replace(/[`*_\d\s.,:;!?()\-–—"'«»|#]/g, "")));
});
return box.innerHTML;
}
// ---------------------------------------------------------------- backends (each yields the full text so far)
async function* readSSE(res, onEvent) {
const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ""; let event = "message";
for (;;) {
const { value, done } = await reader.read(); if (done) return;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).replace(/\r$/, ""); buf = buf.slice(i + 1);
if (line.startsWith("event:")) { event = line.slice(6).trim(); continue; }
if (line.startsWith("data:")) { const r = onEvent(event, line.slice(5).trim()); if (r === null) return; if (r !== undefined) yield r; }
if (line === "") event = "message";
}
}
}
async function* streamOpenAI(messages, apiBase) {
const lang = detectLang([...messages].reverse().find((m) => m.role === "user")?.content || "");
const res = await fetch((apiBase !== undefined ? apiBase : (window.DIBA_API_BASE || "")) + "/v1/chat/completions", {
method: "POST", headers: { "Content-Type": "application/json" }, signal: S.controller.signal,
body: JSON.stringify({
model: "diba", stream: true, temperature: S.settings.temperature, max_tokens: S.settings.max_tokens,
messages: [{ role: "system", content: SYSTEM[lang] }, ...messages.slice(-16)], chat_template_kwargs: { enable_thinking: false },
}),
});
if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
let text = "";
yield* readSSE(res, (ev, data) => {
if (data === "[DONE]") return null;
try { const d = JSON.parse(data).choices?.[0]?.delta?.content; if (d) { text += d; return text; } } catch (e) { /* keep-alive */ }
return undefined;
});
}
// ZeroGPU: the huggingface.co page that embeds this Space hands out per-user GPU headers (X-IP-Token) over postMessage.
// Gradio's own frontend does this handshake; our custom UI must do it too, otherwise ZeroGPU rejects browser requests.
const HF_PARENT = location.hostname.includes(".dev.") ? `https://moon-${location.hostname.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co";
let zeroGpuSupported = false;
window.addEventListener("message", (e) => { if (e.data === "supports-zerogpu-headers") zeroGpuSupported = true; });
if (window.parent !== window && (location.hostname.endsWith(".hf.space") || location.hostname.includes(".dev."))) {
try { window.parent.postMessage("supports-zerogpu-headers", HF_PARENT); } catch (e) { /* not embedded */ }
}
function zeroGpuHeaders() {
if (!(zeroGpuSupported && window.parent !== window)) return Promise.resolve(null);
return new Promise((resolve) => {
const ch = new MessageChannel(); const timer = setTimeout(() => { ch.port1.close(); resolve(null); }, 4000);
ch.port1.onmessage = ({ data }) => { clearTimeout(timer); ch.port1.close(); resolve(data && typeof data === "object" ? data : null); };
try { window.parent.postMessage("zerogpu-headers", HF_PARENT, [ch.port2]); } catch (e) { clearTimeout(timer); resolve(null); }
});
}
// ---------------------------------------------------------------- input guard: random keyboard mashing gets a clarification, not a made-up answer
function looksLikeGibberish(text) {
const t = (text || "").trim();
if (t.length < 10 || /```|https?:\/\/|[{}();=<>\[\]\\/|$#@%^&*_+]/.test(t) || /\d{3,}/.test(t)) return false;
const words = t.split(/[\s.,،؛;:!?؟«»"'-]+/).filter(Boolean);
const fa = words.filter((w) => /^[-ۿ]+$/.test(w));
const la = words.filter((w) => /^[A-Za-z]+$/.test(w));
const letters = t.replace(/[^A-Za-z-ۿ]/g, "");
if (letters.length < 10) return false;
const longFa = fa.filter((w) => w.length >= 17); // Persian words are short; long runs without spaces are mashing
const longLa = la.filter((w) => w.length >= 9 && ((w.match(/[aeiouy]/gi) || []).length / w.length) < 0.2);
const garbageChars = longFa.concat(longLa).join("").length;
return garbageChars / letters.length > 0.6;
}
function clarifyText(text) {
const fa = (text.match(/[-ۿ]/g) || []).length >= (text.match(/[A-Za-z]/g) || []).length;
return fa
? "متوجه منظورتان نشدم؛ به نظر میرسد متن بهصورت اتفاقی تایپ شده است. لطفاً سؤال یا درخواستتان را واضحتر بنویسید تا دقیق پاسخ بدهم."
: "I couldn't understand that message; it looks like random characters. Could you rephrase your question or request so I can help?";
}
let gradioWarm = false;
async function* streamGradio(messages) {
const base = window.location.origin + window.location.pathname.replace(/\/+$/, "");
const zg = await zeroGpuHeaders(); // logged-in user's ZeroGPU token from huggingface.co (without it requests are refused)
const start = await fetch(base + "/gradio_api/call/chat_stream", {
method: "POST", headers: { "Content-Type": "application/json", ...(zg || {}) }, signal: S.controller.signal,
body: JSON.stringify({ data: [JSON.stringify(messages.slice(-16)), S.settings.temperature, S.settings.max_tokens, S.settings.model] }),
});
if (!start.ok) throw new Error("HTTP " + start.status);
const { event_id } = await start.json();
const res = await fetch(base + "/gradio_api/call/chat_stream/" + event_id, { signal: S.controller.signal });
if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
yield* readSSE(res, (ev, data) => {
if (ev === "error") { let msg = ""; try { const d = JSON.parse(data); msg = (d && (d.message || d.error)) || (typeof d === "string" ? d : ""); } catch (e) { msg = data || ""; } const err = new Error(msg || "gradio error"); err.detail = msg; throw err; }
if (ev === "generating" || ev === "complete") {
let out;
try { const arr = JSON.parse(data); if (Array.isArray(arr) && typeof arr[0] === "string") { gradioWarm = true; out = arr[0]; } } catch (e) { /* heartbeat */ }
if (ev === "complete") { if (out !== undefined) latestFinal = out; return null; }
return out;
}
return undefined;
});
if (latestFinal !== null) { const f = latestFinal; latestFinal = null; yield f; }
}
let latestFinal = null;
// ---------------------------------------------------------------- DOM
const root = document.createElement("div");
root.id = "diba-root";
root.innerHTML = `
Diba v0.2
${ICON.down}
${ICON.gear}
${ICON.down}
`;
const el = {
side: $(".d-side", root), backdrop: $(".d-backdrop", root), list: $(".d-list", root), scroll: $(".d-scroll", root),
welcome: $(".d-welcome", root), thread: $(".d-thread", root), form: $(".d-compose", root), input: $(".d-compose textarea", root),
send: $(".d-send", root), jump: $(".d-jump", root), top: $(".d-top", root), pop: $(".d-pop", root),
attachRow: $(".d-attach-row", root), attachBtn: $(".d-attach", root), fileInput: $(".d-file", root), micBtn: $(".d-mic", root),
voiceProg: $(".d-voice-prog", root),
};
function applyChrome() {
root.setAttribute("dir", t("dir")); root.setAttribute("lang", S.lang); root.setAttribute("data-theme", S.theme);
root.querySelectorAll("[data-t]").forEach((n) => { n.textContent = t(n.getAttribute("data-t")); });
root.querySelectorAll("[data-t-html]").forEach((n) => { n.innerHTML = t(n.getAttribute("data-t-html")); });
root.querySelectorAll("[data-lang]").forEach((b) => b.classList.toggle("on", b.dataset.lang === S.lang));
root.querySelectorAll("[data-theme-btn]").forEach((b) => b.classList.toggle("on", b.dataset.themeBtn === S.theme));
el.input.placeholder = t("placeholder");
$(".d-menu", root).title = t("menu"); $(".d-gear", root).title = t("settings"); el.jump.title = t("jump");
$(".d-temp", root).value = S.settings.temperature; $(".d-temp-v", root).textContent = S.settings.temperature;
$(".d-max", root).value = S.settings.max_tokens; $(".d-max-v", root).textContent = S.settings.max_tokens;
renderModelUI();
el.side.classList.toggle("closed", !S.sideOpen);
el.backdrop.classList.toggle("show", S.sideOpen && window.innerWidth <= 820);
setSendState(); renderList(); renderThread();
}
// Model selector: only shown when the backend offers more than one model (a single model keeps the chrome exactly as before).
function renderModelUI() {
const multi = MODELS.length > 1;
const row = $(".d-model-row", root), sel = $(".d-model-sel", root), pill = $(".d-model-pill", root);
row.hidden = !multi; pill.hidden = !multi;
if (!multi) return;
sel.innerHTML = MODELS.map((m) => `${esc(m.label)} `).join("");
sel.value = S.settings.model; sel.disabled = S.streaming;
$(".d-model-pill-v", root).textContent = modelLabel();
pill.title = t("model") + ": " + modelLabel(); pill.setAttribute("aria-label", pill.title);
}
function renderList() {
if (!S.convs.length) { el.list.innerHTML = `${esc(t("noChats"))}
`; return; }
el.list.innerHTML = S.convs.map((c) =>
`` +
`${esc(c.title || t("untitled"))} ` +
`${ICON.trash}
`).join("");
}
function renderWelcome() {
el.welcome.innerHTML = `د
${esc(t("welcome"))} ${esc(t("welcomeSub"))}
` +
'' + SUGG[S.lang].map((s, i) => `${esc(s.t)} ${esc(s.q)} `).join("") + "
";
}
function msgHTML(m, idx, isLast) {
if (m.role === "user") {
let inner = "";
if (Array.isArray(m.content)) {
const imgs = m.content.filter((p) => p && p.type === "image").map((p) => ` `).join("");
const txt = m.content.filter((p) => p && p.type === "text").map((p) => p.text).join(" ");
inner = (imgs ? `${imgs}
` : "") + (txt ? `${esc(txt)}
` : "");
} else { inner = `${esc(m.content)}
`; }
return ``;
}
let body;
if (m.error) body = `${esc(t("error"))}${m.errorDetail ? `
${esc(m.errorDetail)}
` : ""}
`;
else if (m.content) body = `${renderMarkdown(m.content)}
`;
else body = `
` + (m.cold ? `${esc(t("coldStart"))}
` : "");
const actions = m.pending ? "" : `${ICON.copy}${esc(t("copy"))} ` +
(isLast ? `${ICON.regen}${esc(t("regenerate"))} ` : "") + "
";
return ``;
}
function renderThread() {
const c = current();
const empty = !c || !c.messages.length;
el.welcome.style.display = empty ? "block" : "none";
el.thread.style.display = empty ? "none" : "flex";
if (empty) { renderWelcome(); el.thread.innerHTML = ""; return; }
el.thread.innerHTML = c.messages.map((m, i) => msgHTML(m, i, i === c.messages.length - 1)).join("");
}
function updateLastBot() {
const c = current(); if (!c) return;
const idx = c.messages.length - 1; const node = el.thread.querySelector(`.d-msg[data-idx="${idx}"]`);
const html = msgHTML(c.messages[idx], idx, true);
if (node) node.outerHTML = html; else el.thread.insertAdjacentHTML("beforeend", html);
}
// ---------------------------------------------------------------- scrolling: follow only when the reader is at the bottom
let stick = true;
const nearBottom = () => el.scroll.scrollHeight - el.scroll.scrollTop - el.scroll.clientHeight < 90;
el.scroll.addEventListener("scroll", () => {
stick = nearBottom();
el.jump.classList.toggle("show", !stick && el.thread.style.display !== "none");
el.top.classList.toggle("shadow", el.scroll.scrollTop > 4);
});
const toBottom = (force) => { if (force || stick) { el.scroll.scrollTop = el.scroll.scrollHeight; stick = true; el.jump.classList.remove("show"); } };
// ---------------------------------------------------------------- actions
function newChat() {
if (S.streaming) stopStream();
S.currentId = null; renderList(); renderThread(); el.input.focus();
if (window.innerWidth <= 820) { S.sideOpen = false; applyChrome(); }
}
async function runAssistant() {
const c = current(); if (!c) return;
const history = c.messages.filter((m) => !m.error && m.content).map((m) => {
let content = m.content;
if (m.rag && m.role === "user") { // RAG: inject retrieved context only into the sent copy, not the stored/shown message
if (typeof content === "string") content = m.rag + "\n\n" + content;
else if (Array.isArray(content)) content = [{ type: "text", text: m.rag }, ...content];
}
return { role: m.role, content };
});
const lastUser = [...c.messages].reverse().find((m) => m.role === "user");
if (lastUser && typeof lastUser.content === "string" && looksLikeGibberish(lastUser.content)) {
c.messages.push({ role: "assistant", content: clarifyText(lastUser.content) });
c.updated = Date.now(); saveConvs(); renderThread(); toBottom(true); return;
}
const bot = { role: "assistant", content: "", pending: true, cold: BACKEND === "gradio" && !gradioWarm };
c.messages.push(bot); renderThread(); toBottom(true);
S.streaming = true; S.controller = new AbortController(); setSendState();
let frame = 0; let latest = "";
const paint = () => { frame = 0; bot.content = latest; updateLastBot(); toBottom(false); };
try {
const gen = BACKEND === "gradio" ? streamGradio(history) : streamOpenAI(history);
try {
for await (const text of gen) { latest = text; if (!frame) frame = requestAnimationFrame(paint); }
} catch (e) {
// GPU quota exhausted (or GPU unavailable) before any text: answer through the CPU endpoint instead of failing
const reason = String((e && (e.detail || e.message)) || "");
const fallback = window.DIBA_FALLBACK_API;
// On the direct *.hf.space link there is no ZeroGPU token (it only arrives when embedded in huggingface.co),
// so GPU calls are rejected with an empty error. Fall back to the CPU endpoint for any GPU failure, text-only.
if (BACKEND === "gradio" && fallback && !latest && !(S.controller && S.controller.signal.aborted)) {
console.warn("[diba] GPU unavailable, using CPU endpoint:", reason || "(no detail)");
bot.cold = false; updateLastBot();
const textHistory = history.map((m) => ({ role: m.role, content: typeof m.content === "string" ? m.content : (Array.isArray(m.content) ? m.content.filter((p) => p && p.type === "text").map((p) => p.text).join("\n") : String(m.content || "")) })).filter((m) => m.content);
for await (const text of streamOpenAI(textHistory, fallback)) { latest = text; if (!frame) frame = requestAnimationFrame(paint); }
} else {
throw e;
}
}
} catch (e) {
if (!(S.controller && S.controller.signal.aborted)) { bot.error = !latest; bot.errorDetail = e && e.detail ? String(e.detail).slice(0, 300) : ""; console.error("[diba]", e); }
} finally {
if (frame) cancelAnimationFrame(frame);
bot.content = latest; bot.pending = false; bot.cold = false;
if (!bot.content && !bot.error) c.messages.pop();
S.streaming = false; S.controller = null; c.updated = Date.now();
saveConvs(); renderThread(); toBottom(false); setSendState();
}
}
async function sendMessage(text) {
text = (text || "").trim();
const imgs = (S.pendingImages || []).slice();
const docs = (S.pendingDocs || []).slice();
if ((!text && !imgs.length && !docs.length) || S.streaming) return;
let c = current();
const title = (text || (imgs.length ? "تصویر" : "فایل")).slice(0, 60);
if (!c) { c = { id: uid(), title, messages: [], updated: Date.now() }; S.convs.unshift(c); S.currentId = c.id; }
let userText = text;
if (docs.length) {
const blocks = docs.map((d) => (S.lang === "fa" ? `محتوای فایل «${d.name}»:` : `Contents of file "${d.name}":`) + "\n\n" + d.text).join("\n\n---\n\n");
userText = blocks + (text ? "\n\n" + text : "");
}
const content = imgs.length ? imgs.map((src) => ({ type: "image", image: src })).concat(userText ? [{ type: "text", text: userText }] : []) : userText;
const msg = { role: "user", content };
c.messages.push(msg);
S.pendingImages = []; S.pendingDocs = []; renderAttach();
S.convs = [c, ...S.convs.filter((x) => x.id !== c.id)];
el.input.value = ""; autoGrow(); saveConvs(); renderList();
if (window.DIBA_RAG && text && (S.ragChunks || []).length) { // retrieve document context for this question (injected only into the sent copy)
try { msg.rag = await retrieveContext(text); } catch (e) { console.error("[diba] rag", e); }
}
runAssistant();
}
function stopStream() { if (S.controller) S.controller.abort(); }
function setSendState() {
el.send.classList.toggle("stop", S.streaming);
el.send.innerHTML = S.streaming ? ICON.stop : ICON.send;
el.send.title = S.streaming ? t("stop") : t("send");
el.send.setAttribute("aria-label", el.send.title);
const sel = $(".d-model-sel", root); if (sel) sel.disabled = S.streaming; // switching models mid-reply would mix outputs
el.send.disabled = !S.streaming && !el.input.value.trim() && !(S.pendingImages && S.pendingImages.length) && !(S.pendingDocs && S.pendingDocs.length);
}
function autoGrow() { el.input.style.height = "auto"; el.input.style.height = Math.min(el.input.scrollHeight, 200) + "px"; setSendState(); }
function copyText(text, btn) {
const label = btn.querySelector("span"); const icon = btn.querySelector("svg");
const done = () => {
const oldLabel = label ? label.textContent : ""; const oldIcon = icon ? icon.outerHTML : "";
if (label) label.textContent = t("copied"); if (icon) icon.outerHTML = ICON.check;
setTimeout(() => { if (label) label.textContent = oldLabel; const s = btn.querySelector("svg"); if (s && oldIcon) s.outerHTML = oldIcon; }, 1400);
};
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done);
else { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (err) { /* ignore */ } ta.remove(); done(); }
}
// ---------------------------------------------------------------- events
el.form.addEventListener("submit", (e) => { e.preventDefault(); if (S.streaming) stopStream(); else sendMessage(el.input.value); });
el.input.addEventListener("input", autoGrow);
el.input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (!S.streaming) sendMessage(el.input.value); } });
$(".d-new", root).addEventListener("click", newChat);
$(".d-menu", root).addEventListener("click", () => { S.sideOpen = !S.sideOpen; applyChrome(); });
el.backdrop.addEventListener("click", () => { S.sideOpen = false; applyChrome(); });
el.jump.addEventListener("click", () => toBottom(true));
$(".d-gear", root).addEventListener("click", (e) => { e.stopPropagation(); el.pop.classList.toggle("show"); });
$(".d-model-pill", root).addEventListener("click", (e) => { e.stopPropagation(); el.pop.classList.toggle("show"); if (el.pop.classList.contains("show")) $(".d-model-sel", root).focus(); });
$(".d-model-sel", root).addEventListener("change", (e) => {
const id = e.target.value; if (!MODELS.some((m) => m.id === id)) return;
S.settings.model = id; store.set("settings", S.settings); renderModelUI();
});
document.addEventListener("click", (e) => { if (!el.pop.contains(e.target) && !e.target.closest(".d-gear") && !e.target.closest(".d-model-pill")) el.pop.classList.remove("show"); });
$(".d-temp", root).addEventListener("input", (e) => { S.settings.temperature = parseFloat(e.target.value); $(".d-temp-v", root).textContent = S.settings.temperature; store.set("settings", S.settings); });
$(".d-max", root).addEventListener("input", (e) => { S.settings.max_tokens = parseInt(e.target.value, 10); $(".d-max-v", root).textContent = S.settings.max_tokens; store.set("settings", S.settings); });
root.addEventListener("click", (e) => {
const b = e.target.closest("button, .d-conv"); if (!b) return;
if (b.dataset.lang) { S.lang = b.dataset.lang; store.set("lang", S.lang); applyChrome(); return; }
if (b.dataset.themeBtn) { S.theme = b.dataset.themeBtn; store.set("theme", S.theme); applyChrome(); return; }
if (b.dataset.sugg !== undefined) { sendMessage(SUGG[S.lang][+b.dataset.sugg].q); return; }
if (b.dataset.del) { e.stopPropagation(); S.convs = S.convs.filter((c) => c.id !== b.dataset.del); if (S.currentId === b.dataset.del) S.currentId = null; saveConvs(); renderList(); renderThread(); return; }
if (b.classList.contains("d-conv")) { if (S.streaming) stopStream(); S.currentId = b.dataset.id; renderList(); renderThread(); toBottom(true); if (window.innerWidth <= 820) { S.sideOpen = false; applyChrome(); } return; }
if (b.dataset.copyMsg !== undefined) { const m = current().messages[+b.dataset.copyMsg]; copyText(m.content, b); return; }
if (b.hasAttribute("data-copy-code")) { copyText(b.closest(".d-code").querySelector("code").textContent, b); return; }
if (b.hasAttribute("data-regen")) { const c = current(); if (!c || S.streaming) return; if (c.messages[c.messages.length - 1].role === "assistant") c.messages.pop(); runAssistant(); }
});
root.addEventListener("keydown", (e) => { const conv = e.target.closest && e.target.closest(".d-conv"); if (conv && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); conv.click(); } });
window.addEventListener("resize", () => { if (window.innerWidth > 820 && !S.sideOpen) { S.sideOpen = true; applyChrome(); } });
// ---------------------------------------------------------------- demo seed for screenshots (#demo)
if (location.hash === "#demo") {
S.convs = [{ id: "demo", title: "مقایسهی روشهای کپی آرایه", updated: Date.now(), messages: [
{ role: "user", content: "روشهای کپی آرایه در جاوااسکریپت را مقایسه کن." },
{ role: "assistant", content: "## کپی سطحی در برابر کپی عمیق\n\nدر جاوااسکریپت **کپی سطحی** فقط لایهی اول را کپی میکند، اما **کپی عمیق** همهی لایهها را.\n\n| روش | نوع کپی | اشیای تو در تو |\n|---|---|---|\n| `[...arr]` | سطحی | مشترک میمانند |\n| `arr.slice()` | سطحی | مشترک میمانند |\n| `structuredClone(arr)` | عمیق | مستقل میشوند |\n\n### نمونه\n\n```javascript\nconst original = [{ id: 1 }, { id: 2 }];\nconst shallow = [...original];\nconst deep = structuredClone(original);\n\nshallow[0].id = 99; // original هم تغییر میکند\nconsole.log(original[0].id); // 99\n```\n\n> برای دادههایی که تابع یا DOM ندارند، `structuredClone` سادهترین انتخاب است.\n\n1. اگر فقط ترتیب را عوض میکنید، کپی سطحی کافی است.\n2. اگر اشیای داخلی را تغییر میدهید، کپی عمیق بگیرید." },
{ role: "user", content: "Show the same idea in Python." },
{ role: "assistant", content: "Python has the same distinction:\n\n- **Shallow copy** with `list.copy()` or `copy.copy()`\n- **Deep copy** with `copy.deepcopy()`\n\n```python\nimport copy\n\noriginal = [{\"id\": 1}, {\"id\": 2}]\nshallow = original.copy()\ndeep = copy.deepcopy(original)\n\nshallow[0][\"id\"] = 99\nprint(original[0][\"id\"]) # 99\n```" },
] }, { id: "demo2", title: "Who was Cyrus the Great?", updated: Date.now(), messages: [] }];
S.currentId = "demo";
}
// ---------------------------------------------------------------- Hugging Face sign-in (ZeroGPU: signed-in users run on their own quota)
const spaceOrigin = () => location.origin;
async function hfWhoami() {
try {
const base = spaceOrigin() + location.pathname.replace(/\/+$/, "");
const start = await fetch(base + "/gradio_api/call/whoami", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{\"data\":[]}" });
if (!start.ok) return null;
const { event_id } = await start.json();
const res = await fetch(base + "/gradio_api/call/whoami/" + event_id);
const text = await res.text();
const line = text.split("\n").find((l) => l.startsWith("data:"));
if (!line) return null;
const arr = JSON.parse(line.slice(5).trim());
const u = arr && arr[0]; return (typeof u === "string" && u) ? u : null;
} catch (e) { return null; }
}
function renderAuth(user) {
const box = $(".d-auth", root); if (!box) return;
box.hidden = false;
if (user) {
box.innerHTML = `${esc(user)}
${esc(t("signOut"))} `;
} else {
box.innerHTML = `${esc(t("signIn"))} ${esc(t("ownQuota"))}
`;
}
box.querySelectorAll("[data-auth]").forEach((b) => b.addEventListener("click", () => {
const url = spaceOrigin() + (b.dataset.auth === "in" ? "/login/huggingface" : "/logout");
window.open(url, "_blank", "noopener"); // OAuth must run top-level, not inside the Space iframe
}));
}
function initAuth() {
if (BACKEND !== "gradio") return; // only the ZeroGPU Space has Hugging Face sign-in
hfWhoami().then(renderAuth);
window.addEventListener("focus", () => hfWhoami().then(renderAuth)); // refresh after the login tab returns
}
// ---------------------------------------------------------------- image attachments (Diba-Vision only)
const VISION = !!window.DIBA_VISION || qp.get("vision") === "1"; // ?vision=1 test hook
function renderAttach() {
const row = el.attachRow; if (!row) return;
const imgs = S.pendingImages || [];
const docs = S.pendingDocs || [];
const rdocs = S.ragDocs || [];
row.hidden = imgs.length === 0 && docs.length === 0 && rdocs.length === 0;
const idxTxt = (d) => d.indexing ? (S.lang === "fa" ? "در حال ایندکس…" : "indexing…") : (S.lang === "fa" ? `${d.n} قطعه` : `${d.n} chunks`);
row.innerHTML =
imgs.map((src, i) => `× `).join("") +
rdocs.map((d, i) => `📚 ${esc(d.name)} ${idxTxt(d)} × `).join("") +
docs.map((d, i) => `📄 ${esc(d.name)} × `).join("");
row.querySelectorAll("[data-rm]").forEach((b) => b.addEventListener("click", () => { S.pendingImages.splice(+b.dataset.rm, 1); renderAttach(); setSendState(); }));
row.querySelectorAll("[data-rmd]").forEach((b) => b.addEventListener("click", () => { S.pendingDocs.splice(+b.dataset.rmd, 1); renderAttach(); setSendState(); }));
row.querySelectorAll("[data-rmr]").forEach((b) => b.addEventListener("click", () => {
const d = S.ragDocs[+b.dataset.rmr]; S.ragDocs.splice(+b.dataset.rmr, 1);
S.ragChunks = (S.ragChunks || []).filter((ch) => ch.doc !== d.name); renderAttach(); setSendState();
}));
setSendState();
}
function readImage(file) {
return new Promise((resolve) => {
const r = new FileReader();
r.onload = () => {
const img = new Image();
img.onload = () => {
const max = 1280, scale = Math.min(1, max / Math.max(img.width, img.height));
const cv = document.createElement("canvas");
cv.width = Math.round(img.width * scale); cv.height = Math.round(img.height * scale);
cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height);
try { resolve(cv.toDataURL("image/jpeg", 0.85)); } catch (e) { resolve(r.result); }
};
img.onerror = () => resolve(r.result);
img.src = r.result;
};
r.readAsDataURL(file);
});
}
let pdfLibPromise = null;
function ensurePdfLib() {
if (window.pdfjsLib) return Promise.resolve(window.pdfjsLib);
if (!pdfLibPromise) pdfLibPromise = loadScript(VENDOR.pdfjs).then(() => {
try { window.pdfjsLib.GlobalWorkerOptions.workerSrc = VENDOR.pdfWorker; } catch (e) {}
return window.pdfjsLib;
});
return pdfLibPromise;
}
async function pdfToImages(file) {
const lib = await ensurePdfLib();
const pdf = await lib.getDocument({ data: await file.arrayBuffer() }).promise;
const out = [], pages = Math.min(pdf.numPages, 5);
for (let p = 1; p <= pages; p++) {
const page = await pdf.getPage(p);
const base = page.getViewport({ scale: 1 });
const vp = page.getViewport({ scale: Math.min(2, 1400 / Math.max(base.width, base.height)) });
const cv = document.createElement("canvas");
cv.width = Math.round(vp.width); cv.height = Math.round(vp.height);
await page.render({ canvasContext: cv.getContext("2d"), viewport: vp }).promise;
try { out.push(cv.toDataURL("image/jpeg", 0.85)); } catch (e) {}
}
return out;
}
const RAG = !!window.DIBA_RAG;
async function addFiles(files) {
S.pendingImages = S.pendingImages || [];
S.pendingDocs = S.pendingDocs || [];
S.ragChunks = S.ragChunks || []; S.ragDocs = S.ragDocs || [];
for (const f of files) {
const isPdf = f.type === "application/pdf" || /\.pdf$/i.test(f.name);
if (f.type.startsWith("image/")) {
if (S.pendingImages.length >= 6) continue;
S.pendingImages.push(await readImage(f)); renderAttach();
} else if (isPdf) {
let text = "";
if (RAG) { try { text = await pdfText(f); } catch (e) {} }
if (RAG && text.replace(/\s/g, "").length > 200) {
await indexDoc(f.name, text); // text-PDF → RAG
} else {
try { for (const src of await pdfToImages(f)) { if (S.pendingImages.length >= 6) break; S.pendingImages.push(src); } renderAttach(); } // scanned PDF → vision
catch (e) { console.error("[diba] pdf failed", e); }
}
} else if (f.size <= 2 * 1024 * 1024) { // text / code file
let text = ""; try { text = await f.text(); } catch (e) { continue; }
if (RAG) await indexDoc(f.name, text);
else if ((S.pendingDocs || []).length < 4) { S.pendingDocs.push({ name: f.name, text: text.slice(0, 20000) }); renderAttach(); }
}
}
}
// ---------------------------------------------------------------- document RAG (Diba-Embed) + PDF text
async function pdfText(file) {
const lib = await ensurePdfLib();
const pdf = await lib.getDocument({ data: await file.arrayBuffer() }).promise;
let out = "", pages = Math.min(pdf.numPages, 40);
for (let p = 1; p <= pages; p++) {
const tc = await (await pdf.getPage(p)).getTextContent();
out += tc.items.map((it) => it.str).join(" ") + "\n\n";
}
return out;
}
function chunkText(text, size = 900, overlap = 150) {
text = String(text).replace(/[ \t]+\n/g, "\n").trim();
const paras = text.split(/\n{2,}/);
const chunks = []; let cur = "";
for (let p of paras) {
while (p.length > size * 1.6) { chunks.push(p.slice(0, size).trim()); p = p.slice(size - overlap); }
if ((cur + "\n\n" + p).length > size && cur) { chunks.push(cur.trim()); cur = cur.slice(Math.max(0, cur.length - overlap)); }
cur += (cur ? "\n\n" : "") + p;
}
if (cur.trim()) chunks.push(cur.trim());
return chunks.filter((c) => c.replace(/\s/g, "").length > 12).slice(0, 80);
}
async function callEmbed(texts, isQuery) {
const base = window.location.origin + window.location.pathname.replace(/\/+$/, "");
const zg = await zeroGpuHeaders();
const start = await fetch(base + "/gradio_api/call/embed", {
method: "POST", headers: { "Content-Type": "application/json", ...(zg || {}) },
body: JSON.stringify({ data: [JSON.stringify(texts), !!isQuery] }),
});
if (!start.ok) throw new Error("embed HTTP " + start.status);
const { event_id } = await start.json();
const res = await fetch(base + "/gradio_api/call/embed/" + event_id);
const lines = (await res.text()).split("\n").filter((l) => l.indexOf("data:") === 0);
for (let i = lines.length - 1; i >= 0; i--) {
try { const d = JSON.parse(lines[i].slice(5).trim()); if (Array.isArray(d) && typeof d[0] === "string") return JSON.parse(d[0]); } catch (e) {}
}
throw new Error("embed: no result");
}
async function indexDoc(name, text) {
const chunks = chunkText(text); if (!chunks.length) return;
const doc = { name, n: chunks.length, indexing: true };
S.ragDocs.push(doc); renderAttach();
try {
const vecs = await callEmbed(chunks, false);
for (let i = 0; i < chunks.length && i < vecs.length; i++) S.ragChunks.push({ text: chunks[i], vec: vecs[i], doc: name });
doc.indexing = false; doc.n = Math.min(chunks.length, vecs.length);
} catch (e) { console.error("[diba] index failed", e); doc.indexing = false; doc.n = 0; }
renderAttach(); setSendState();
}
function dot(a, b) { let s = 0, n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) s += a[i] * b[i]; return s; }
async function retrieveContext(query) {
const chunks = S.ragChunks || []; if (!chunks.length) return "";
const [qv] = await callEmbed([query], true);
const top = chunks.map((ch) => ({ ch, s: dot(qv, ch.vec) })).sort((a, b) => b.s - a.s).slice(0, 4);
const head = S.lang === "fa" ? "زمینه از اسناد کاربر (پاسخ را بر پایهی همین بده):" : "Context from the user's documents (answer based on this):";
return head + "\n\n" + top.map((x) => `«${x.ch.doc}»\n${x.ch.text}`).join("\n\n---\n\n");
}
// ---------------------------------------------------------------- voice input (Diba-STT, in the browser)
const STT = { model: null, lib: null, rec: null, ctx: null, src: null, node: null, stream: null, on: false, base: "" };
const faNum = (s) => String(s).replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[d]);
function showVoiceProg(mode) {
const p = el.voiceProg; if (!p) return;
p.hidden = false;
const fa = S.lang === "fa";
const done = mode === "done";
const label = done ? (fa ? "مدل صوتی آماده شد ✓" : "Voice model ready ✓")
: (fa ? "در حال دانلود مدل صوتی… (~۵۳ مگابایت، فقط بار اول)" : "Downloading voice model… (~53 MB, first time only)");
p.classList.toggle("done", done);
p.innerHTML = `${label} `;
}
function hideVoiceProg() { if (el.voiceProg) el.voiceProg.hidden = true; }
function scrubConsole(on) { // hide the voice library's own log lines (no brand leak in the console)
if (on) {
if (STT._c) return;
STT._c = { log: console.log, info: console.info, warn: console.warn, debug: console.debug, error: console.error };
const f = (fn) => function () { try { for (let i = 0; i < arguments.length; i++) if (typeof arguments[i] === "string" && /vosk|kaldi|emscripten/i.test(arguments[i])) return; } catch (e) {} return fn.apply(console, arguments); };
console.log = f(STT._c.log); console.info = f(STT._c.info); console.warn = f(STT._c.warn); console.debug = f(STT._c.debug); console.error = f(STT._c.error);
} else if (STT._c) {
Object.assign(console, STT._c); STT._c = null;
}
}
async function ensureVoskModel() {
if (STT.model) return STT.model;
scrubConsole(true);
try {
if (!STT.lib) {
await loadScript((window.DIBA_ASSET_BASE || "") + "vendor/diba-voice.js");
STT.lib = window.Vosk; try { delete window.Vosk; } catch (e) { window.Vosk = undefined; } // keep a private ref, leave no global trace
}
showVoiceProg("download"); // single download inside the worker; indeterminate bar with a clear message
const load = STT.lib.createModel(window.DIBA_STT_MODEL);
const guard = new Promise((_, rej) => setTimeout(() => rej(new Error("voice model load timeout")), 180000));
try { STT.model = await Promise.race([load, guard]); }
catch (e) { hideVoiceProg(); throw e; }
showVoiceProg("done"); setTimeout(hideVoiceProg, 1300);
return STT.model;
} finally { scrubConsole(false); }
}
function setMic(on) {
STT.on = on;
el.micBtn.classList.toggle("live", on);
el.micBtn.title = on ? (S.lang === "fa" ? "توقف گفتار" : "Stop voice") : (S.lang === "fa" ? "گفتار به متن" : "Voice to text");
}
async function startMic() {
el.micBtn.classList.add("busy");
try {
const model = await ensureVoskModel();
STT.base = el.input.value.trim();
STT.rec = new model.KaldiRecognizer(16000);
STT.rec.on("result", (m) => { const tx = m.result && m.result.text; if (tx) { STT.base = (STT.base ? STT.base + " " : "") + tx; el.input.value = STT.base; autoGrow(); } });
STT.rec.on("partialresult", (m) => { const p = (m.result && m.result.partial) || ""; el.input.value = (STT.base ? STT.base + " " : "") + p; autoGrow(); });
STT.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } });
STT.ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
STT.src = STT.ctx.createMediaStreamSource(STT.stream);
STT.node = STT.ctx.createScriptProcessor(4096, 1, 1);
STT.node.onaudioprocess = (e) => { try { STT.rec.acceptWaveform(e.inputBuffer); } catch (err) {} };
STT.src.connect(STT.node); STT.node.connect(STT.ctx.destination);
setMic(true);
} catch (e) { console.error("[diba] mic", e); hideVoiceProg(); }
finally { el.micBtn.classList.remove("busy"); }
}
function stopMic() {
try { if (STT.node) { STT.node.disconnect(); STT.node.onaudioprocess = null; } } catch (e) {}
try { if (STT.src) STT.src.disconnect(); } catch (e) {}
try { if (STT.stream) STT.stream.getTracks().forEach((t) => t.stop()); } catch (e) {}
try { if (STT.ctx) STT.ctx.close(); } catch (e) {}
try { if (STT.rec) STT.rec.remove(); } catch (e) {}
STT.node = STT.src = STT.stream = STT.ctx = STT.rec = null;
setMic(false); setSendState();
}
function initMic() {
if (!window.DIBA_STT || !el.micBtn) return;
el.micBtn.hidden = false; setMic(false);
el.micBtn.addEventListener("click", () => { STT.on ? stopMic() : startMic(); });
}
function initVision() {
if (!VISION) return;
el.attachBtn.hidden = false; el.attachBtn.title = S.lang === "fa" ? "افزودن عکس یا فایل (PDF، متن)" : "Add image or file (PDF, text)";
el.attachBtn.addEventListener("click", () => el.fileInput.click());
el.fileInput.addEventListener("change", (e) => { addFiles([...e.target.files]); e.target.value = ""; });
el.input.addEventListener("paste", (e) => {
const imgs = [...(e.clipboardData?.items || [])].filter((it) => it.type.startsWith("image/")).map((it) => it.getAsFile()).filter(Boolean);
if (imgs.length) { e.preventDefault(); addFiles(imgs); }
});
const wrap = $(".d-compose-wrap", root);
wrap.addEventListener("dragover", (e) => { e.preventDefault(); wrap.classList.add("drag"); });
wrap.addEventListener("dragleave", () => wrap.classList.remove("drag"));
wrap.addEventListener("drop", (e) => { e.preventDefault(); wrap.classList.remove("drag"); if (e.dataTransfer?.files?.length) addFiles([...e.dataTransfer.files]); });
}
// ---------------------------------------------------------------- boot
function boot() {
injectFonts();
document.body.appendChild(root); document.body.classList.add("diba-lock");
applyChrome(); toBottom(true);
Promise.all([loadScript(VENDOR.marked), loadScript(VENDOR.purify), loadScript(VENDOR.hljs)])
.then(() => { renderThread(); toBottom(true); })
.catch((e) => console.error("[diba] vendor load failed", e));
initAuth(); initVision(); initMic();
if (window.innerWidth > 820) el.input.focus();
const autoQ = qp.get("q"); // test hook: ?q=... sends one message on load
if (autoQ && location.hash !== "#demo") setTimeout(() => { newChat(); sendMessage(autoQ); }, 1200);
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
})();