import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js"; import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify/dist/purify.es.mjs"; import renderMathInElement from "https://cdn.jsdelivr.net/npm/katex/dist/contrib/auto-render.mjs"; import hljs from "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/es/highlight.min.js"; const banner = document.getElementById("banner"); const bannerText = document.getElementById("banner-text"); let bannerTimer; function hideBanner() { banner.hidden = true; clearTimeout(bannerTimer); } document.getElementById("banner-close").addEventListener("click", hideBanner); function showBanner(message, { autoHide = false, notice = false } = {}) { bannerText.textContent = message; banner.classList.toggle("notice", notice); banner.hidden = false; clearTimeout(bannerTimer); if (autoHide) { bannerTimer = setTimeout(hideBanner, 4000); } } let client = null; try { // Subscribe to "status" in addition to "data": the client only publishes the // event types listed here to the submit() async iterator, and a raised // gr.Error arrives as a {type:"status", stage:"error"} event. Without "status" // the error never reaches the for-await loop and the failure is swallowed. client = await Client.connect(location.origin, { events: ["data", "status"] }); } catch { showBanner("Could not connect to the server. Please reload the page."); } const chatScroll = document.getElementById("chat-scroll"); const chatLog = document.getElementById("chat-log"); const messageBox = document.getElementById("message"); const sendBtn = document.getElementById("send-btn"); const thinkingToggle = document.getElementById("thinking"); const fileInput = document.getElementById("file-input"); const attachBtn = document.getElementById("attach-btn"); const fileChips = document.getElementById("file-chips"); const systemPromptBox = document.getElementById("system-prompt"); const maxTokensInput = document.getElementById("max-tokens"); const maxTokensOut = document.getElementById("max-tokens-out"); const imageBudgetSelect = document.getElementById("image-budget"); const temperatureInput = document.getElementById("temperature"); const topPInput = document.getElementById("top-p"); const topKInput = document.getElementById("top-k"); const repPenaltyInput = document.getElementById("rep-penalty"); const dropOverlay = document.getElementById("drop-overlay"); const lightbox = document.getElementById("lightbox"); const newChatBtn = document.getElementById("new-chat-btn"); const app = document.querySelector(".app"); const menuBtn = document.getElementById("menu-btn"); const sidebarBackdrop = document.getElementById("sidebar-backdrop"); const editLastBtn = document.getElementById("edit-last-btn"); const ACCEPTED_EXTENSIONS = [ ".jpg", ".jpeg", ".png", ".webp", ".wav", ".mp3", ".flac", ".ogg", ".mp4", ".mov", ".avi", ".webm", ]; function isAccepted(file) { const name = file.name.toLowerCase(); return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext)); } // Media kind from a MIME type (local files) or a URL extension (examples). function typeKind(mime) { if (mime.startsWith("video/")) return "video"; if (mime.startsWith("audio/")) return "audio"; return "image"; } function extKind(url) { const ext = url.split("?")[0].split(".").pop().toLowerCase(); if (["mp4", "mov", "avi", "webm"].includes(ext)) return "video"; if (["mp3", "wav", "flac", "ogg"].includes(ext)) return "audio"; return "image"; } // Free any object URLs an attachment holds (remote-URL items have none). function revokeItem(item) { if (item.file) URL.revokeObjectURL(item.url); } // Attachments are either a local File (uploaded on send) or a remote URL // (passed straight to the model, e.g. example media). Both carry { kind, url } // for display; local items add `file`, remote items add `remoteUrl`. function addFiles(fileList) { const all = Array.from(fileList); const accepted = all.filter(isAccepted); for (const file of accepted) { pendingFiles.push({ file, url: URL.createObjectURL(file), kind: typeKind(file.type) }); } if (accepted.length) renderPreviews(); const rejected = all.length - accepted.length; if (rejected > 0) { const noun = rejected === 1 ? "file" : "files"; showBanner(`Skipped ${rejected} unsupported ${noun}.`, { autoHide: true, notice: true }); } } // Conversation history sent back to the backend: {role, text, files}. const history = []; // Files attached to the next message, not yet uploaded: {file, url}. let pendingFiles = []; let currentJob = null; // Set when the user presses Stop, so the turn's end can be told apart from a // failure (a deliberate stop is silent; a failure gets a banner). let cancelled = false; // The most recent sent turn, kept so it can be taken back into the composer. let lastTurn = null; // Bumped whenever a new turn starts or the conversation is reset. A streaming // turn captures this value and bails out of its tail work if it no longer // matches, so a "Clear chat" mid-stream can't resurrect the discarded turn. let turnEpoch = 0; maxTokensInput.addEventListener("input", () => { maxTokensOut.textContent = maxTokensInput.value; }); // Mirror each slider's value into its adjacent . for (const id of ["temperature", "top-p", "top-k", "rep-penalty"]) { const input = document.getElementById(id); const out = document.getElementById(`${id}-out`); input.addEventListener("input", () => { out.textContent = input.value; }); } // Backend parameter -> its control element ids. The allowed ranges, steps, and // defaults are applied from window.PARAM_CONFIG (sourced from params.py), so // they are defined in one place rather than duplicated in this file. const PARAM_CONTROLS = [ { key: "max_new_tokens", input: "max-tokens", out: "max-tokens-out" }, { key: "image_token_budget", input: "image-budget" }, { key: "temperature", input: "temperature", out: "temperature-out" }, { key: "top_p", input: "top-p", out: "top-p-out" }, { key: "top_k", input: "top-k", out: "top-k-out" }, { key: "repetition_penalty", input: "rep-penalty", out: "rep-penalty-out" }, ]; function applyParamConfig() { const config = window.PARAM_CONFIG ?? {}; for (const { key, input, out } of PARAM_CONTROLS) { const spec = config[key]; if (!spec) continue; const el = document.getElementById(input); if (spec.choices) { // Discrete select: build its options from the spec. el.replaceChildren( ...spec.choices.map((choice) => { const opt = document.createElement("option"); opt.value = String(choice); opt.textContent = String(choice); opt.selected = choice === spec.default; return opt; }), ); } else { // Range slider. el.min = spec.min; el.max = spec.max; el.step = spec.step; el.value = spec.default; } if (out) document.getElementById(out).textContent = spec.default; } } applyParamConfig(); messageBox.addEventListener("input", () => { messageBox.style.height = "auto"; messageBox.style.height = `${messageBox.scrollHeight}px`; }); messageBox.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendBtn.click(); } }); attachBtn.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { addFiles(fileInput.files); fileInput.value = ""; }); // Drag-and-drop. A counter avoids flicker when dragging over child elements. let dragDepth = 0; function hasFiles(event) { return Array.from(event.dataTransfer?.types || []).includes("Files"); } window.addEventListener("dragenter", (e) => { if (!hasFiles(e)) return; e.preventDefault(); dragDepth += 1; dropOverlay.hidden = false; }); window.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); }); window.addEventListener("dragleave", (e) => { if (!hasFiles(e)) return; dragDepth -= 1; if (dragDepth <= 0) { dragDepth = 0; dropOverlay.hidden = true; } }); window.addEventListener("drop", (e) => { if (!hasFiles(e)) return; e.preventDefault(); dragDepth = 0; dropOverlay.hidden = true; addFiles(e.dataTransfer.files); }); // Paste images straight from the clipboard. window.addEventListener("paste", (e) => { const files = Array.from(e.clipboardData?.files || []); if (files.length) { e.preventDefault(); addFiles(files); } }); // Lightbox: click a chat image to view it full size. function closeLightbox() { lightbox.hidden = true; lightbox.innerHTML = ""; } function openLightbox(kind, url) { lightbox.innerHTML = ""; let el; if (kind === "audio") { el = document.createElement("audio"); el.controls = true; el.autoplay = true; } else if (kind === "video") { el = document.createElement("video"); el.controls = true; el.autoplay = true; // Play inline; without this, mobile WebKit forces native fullscreen, which // shifts the video out of place and leaves the page scrollable afterwards. el.playsInline = true; el.setAttribute("playsinline", ""); // Don't close on the media itself; let clicks use the native controls. } else { el = document.createElement("img"); el.addEventListener("click", closeLightbox); } el.src = url; lightbox.appendChild(el); // The media can fill the screen, making the backdrop hard to tap, so give it // an explicit close button. const close = document.createElement("button"); close.className = "lightbox-close"; close.type = "button"; close.setAttribute("aria-label", "Close"); close.textContent = "✕"; close.addEventListener("click", closeLightbox); lightbox.appendChild(close); lightbox.hidden = false; } // Clicking the backdrop (outside the media) closes it; so does Escape. lightbox.addEventListener("click", (e) => { if (e.target === lightbox) closeLightbox(); }); // Escape closes the lightbox first (it's modal); otherwise it dismisses the banner. document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (!lightbox.hidden) { closeLightbox(); } else if (!banner.hidden) { hideBanner(); } }); // Example media is referenced by remote URL (not bundled): the browser shows it // with /