hysts's picture
hysts HF Staff
Report chat errors by raising gr.Error
967e1cb
Raw
History Blame Contribute Delete
29.5 kB
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 <output>.
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 <img>/<video> and the model fetches the URL server-side. This avoids
// redistributing copyrighted media in the repo.
const EXAMPLES = [
{ text: "What is the capital of France?" },
{ text: "What is the water formula?" },
{ text: "Explain quantum entanglement in simple terms." },
{ text: "I want to do a car wash that is 50 meters away, should I walk or drive?" },
{
text: "Write a poem about beer with 4 stanzas. Format the title as an H2 markdown heading and bold the first line of each stanza.",
},
{
text: "Describe this image.",
files: ["https://news.bbc.co.uk/media/images/38107000/jpg/_38107299_ronaldogoal_ap_300.jpg"],
},
{
text: "What are the key similarities between these three images?",
files: [
"https://news.bbc.co.uk/media/images/38107000/jpg/_38107299_ronaldogoal_ap_300.jpg",
"https://ogimg.infoglobo.com.br/in/12547538-502-0e0/FT1086A/94-8705-14.jpg",
"https://amazonasatual.com.br/wp-content/uploads/2021/01/Pele.jpg",
],
},
{
text: "Transcribe the audio.",
files: ["https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/bcn_weather.mp3"],
},
{
text: "Translate to Dutch.",
files: ["https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/bcn_weather.mp3"],
},
{
text: "What is happening in this video?",
files: ["https://huggingface.co/datasets/merve/vlm_test_images/resolve/main/concert.mp4"],
},
];
// Fill the composer from an example. The user still presses send (so they can
// edit first). Example media are remote URLs, attached without uploading.
function useExample(ex) {
messageBox.value = ex.text;
autoResizeMessage();
// Clicking an example replaces the composer, so drop any current attachments.
pendingFiles.forEach(revokeItem);
pendingFiles = (ex.files || []).map((url) => ({ url, remoteUrl: url, kind: extKind(url) }));
renderPreviews();
messageBox.focus();
}
// A small preview for a media example. Images show a thumbnail; audio and video
// use an icon (so the page doesn't download large media just for a thumbnail).
// A count badge marks examples that attach more than one file.
function exampleThumb(files) {
const wrap = document.createElement("span");
wrap.className = "example-media";
const kind = extKind(files[0]);
let thumb;
if (kind === "image") {
thumb = document.createElement("img");
thumb.className = "example-thumb";
thumb.src = files[0];
} else {
thumb = document.createElement("span");
thumb.className = "example-icon";
thumb.textContent = kind === "video" ? "🎬" : "🎵";
}
wrap.appendChild(thumb);
if (files.length > 1) {
const badge = document.createElement("span");
badge.className = "example-count";
badge.textContent = String(files.length);
wrap.appendChild(badge);
}
return wrap;
}
function showEmptyState() {
chatLog.innerHTML = "";
const empty = document.createElement("div");
empty.className = "empty-state";
const hint = document.createElement("p");
hint.className = "empty-hint";
hint.textContent = "Start the conversation below, or try an example:";
empty.appendChild(hint);
const examples = document.createElement("div");
examples.className = "examples";
for (const ex of EXAMPLES) {
const btn = document.createElement("button");
btn.className = "example";
if (ex.files?.length) {
btn.appendChild(exampleThumb(ex.files));
}
const label = document.createElement("span");
label.textContent = ex.text;
btn.appendChild(label);
btn.addEventListener("click", () => useExample(ex));
examples.appendChild(btn);
}
empty.appendChild(examples);
chatLog.appendChild(empty);
}
function autoResizeMessage() {
messageBox.style.height = "auto";
messageBox.style.height = `${messageBox.scrollHeight}px`;
}
// Reset to a fresh conversation without reloading the page.
function resetConversation() {
// Invalidate any in-flight turn so its tail work (after the upload/stream
// awaits) is skipped instead of writing into the now-cleared conversation.
turnEpoch += 1;
if (currentJob) currentJob.cancel();
setGenerating(false);
history.length = 0;
pendingFiles.forEach(revokeItem);
pendingFiles = [];
renderPreviews();
messageBox.value = "";
messageBox.style.height = "auto";
lastTurn = null;
editLastBtn.hidden = true;
showEmptyState();
app.classList.remove("sidebar-open"); // close the drawer on mobile
}
newChatBtn.addEventListener("click", resetConversation);
// Remove a turn's bubbles from the chat log, bringing back the empty state if
// the log is left empty. Shared by the take-back button and the no-answer path.
function removeTurn(userEl, assistantEl) {
userEl?.remove();
assistantEl?.remove();
if (!chatLog.querySelector(".msg")) showEmptyState();
}
// Drop a turn's text and attachments back into the composer for editing.
function loadComposer(text, items) {
messageBox.value = text;
pendingFiles = items;
renderPreviews();
autoResizeMessage();
messageBox.focus();
}
// Take the last sent turn back into the composer to edit and resend.
function editLastTurn() {
if (!lastTurn) return;
history.length = lastTurn.historyLenBefore;
removeTurn(lastTurn.userEl, lastTurn.assistantEl);
loadComposer(lastTurn.text, lastTurn.items);
lastTurn = null;
editLastBtn.hidden = true;
}
editLastBtn.addEventListener("click", editLastTurn);
// Mobile sidebar drawer
menuBtn.addEventListener("click", () => app.classList.add("sidebar-open"));
sidebarBackdrop.addEventListener("click", () => app.classList.remove("sidebar-open"));
function renderPreviews() {
fileChips.innerHTML = "";
pendingFiles.forEach((item, i) => {
const preview = document.createElement("div");
preview.className = "preview";
// Clicking the thumbnail opens the file in the lightbox so it can be
// checked before sending.
if (item.kind === "image") {
const img = document.createElement("img");
img.className = "preview-thumb";
img.src = item.url;
img.addEventListener("click", () => openLightbox("image", item.url));
preview.appendChild(img);
} else if (item.kind === "video") {
const video = document.createElement("video");
video.className = "preview-thumb";
video.src = item.url;
video.addEventListener("click", () => openLightbox("video", item.url));
preview.appendChild(video);
} else {
preview.classList.add("preview-file");
const name = item.file ? item.file.name : item.remoteUrl.split("/").pop().split("?")[0];
const label = document.createElement("span");
label.textContent = `🎵 ${name}`;
label.addEventListener("click", () => openLightbox("audio", item.url));
preview.appendChild(label);
}
const remove = document.createElement("button");
remove.className = "preview-remove";
remove.textContent = "✕";
remove.addEventListener("click", () => {
revokeItem(item);
pendingFiles.splice(i, 1);
renderPreviews();
});
preview.appendChild(remove);
fileChips.appendChild(preview);
});
}
function clearEmptyState() {
chatLog.querySelector(".empty-state")?.remove();
}
function scrollToBottom() {
chatScroll.scrollTop = chatScroll.scrollHeight;
}
function isNearBottom() {
return chatScroll.scrollHeight - chatScroll.scrollTop - chatScroll.clientHeight < 80;
}
const MATH_DELIMITERS = [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false },
];
// Markdown often uses \[ \] and \( \) for math, but a CommonMark renderer
// strips those backslashes, so normalize them to $$ and $ before parsing.
function preprocessMath(text) {
return text
.replace(/\\\[/g, "$$$$")
.replace(/\\\]/g, "$$$$")
.replace(/\\\(/g, "$$")
.replace(/\\\)/g, "$$");
}
// Render assistant markdown: parse, sanitize (defends against HTML the model
// might emit), then typeset math in place.
function renderAssistant(bubble, content) {
bubble.innerHTML = DOMPurify.sanitize(marked.parse(preprocessMath(content)));
// Open links in a new tab. This app is volatile, so following a link in the
// same tab would throw away the whole conversation with no way back.
bubble.querySelectorAll("a[href]").forEach((a) => {
a.target = "_blank";
a.rel = "noopener noreferrer";
});
renderMathInElement(bubble, { delimiters: MATH_DELIMITERS, throwOnError: false });
}
function addCopyButtons(container) {
container.querySelectorAll("pre").forEach((pre) => {
if (pre.parentElement?.classList.contains("code-wrap")) return;
// Wrap the scrolling <pre> in a non-scrolling container so the button can
// stay pinned to the top-right instead of scrolling with the code.
const wrap = document.createElement("div");
wrap.className = "code-wrap";
pre.replaceWith(wrap);
wrap.appendChild(pre);
const btn = document.createElement("button");
btn.className = "copy-btn";
btn.textContent = "Copy";
btn.addEventListener("click", () => {
const code = pre.querySelector("code")?.textContent ?? pre.textContent;
navigator.clipboard.writeText(code);
btn.textContent = "Copied";
setTimeout(() => {
btn.textContent = "Copy";
}, 1500);
});
wrap.appendChild(btn);
});
}
// Syntax-highlight code blocks that the model tagged with a language
// (```python etc.). Untagged blocks are left plain — no auto-detection.
function highlightCode(container) {
container.querySelectorAll('pre code[class*="language-"]').forEach((block) => {
if (block.dataset.highlighted) return;
hljs.highlightElement(block);
});
}
function addUserMessage(text, items) {
clearEmptyState();
const msg = document.createElement("div");
msg.className = "msg user";
for (const { kind, url } of items) {
if (kind === "image") {
const img = document.createElement("img");
img.className = "msg-media";
img.src = url;
img.addEventListener("click", () => openLightbox("image", url));
msg.appendChild(img);
} else if (kind === "video") {
const video = document.createElement("video");
video.className = "msg-media";
video.src = url;
video.muted = true;
video.playsInline = true;
video.setAttribute("playsinline", "");
video.addEventListener("click", () => openLightbox("video", url));
msg.appendChild(video);
} else {
const audio = document.createElement("audio");
audio.src = url;
audio.controls = true;
msg.appendChild(audio);
}
}
if (text) {
const bubble = document.createElement("div");
bubble.className = "bubble";
bubble.textContent = text;
msg.appendChild(bubble);
}
chatLog.appendChild(msg);
scrollToBottom();
return msg;
}
function addAssistantMessage() {
const msg = document.createElement("div");
msg.className = "msg assistant";
const reasoning = document.createElement("details");
reasoning.className = "reasoning";
reasoning.open = true;
reasoning.hidden = true;
const summary = document.createElement("summary");
summary.textContent = "Reasoning";
const reasoningBody = document.createElement("div");
reasoningBody.className = "reasoning-body md";
reasoning.append(summary, reasoningBody);
const bubble = document.createElement("div");
bubble.className = "bubble md";
bubble.innerHTML = '<span class="typing"><i></i><i></i><i></i></span>';
msg.append(reasoning, bubble);
chatLog.appendChild(msg);
scrollToBottom();
return { msg, reasoning, reasoningBody, bubble };
}
// Append a short note under an assistant turn. `kind` is "stopped" or "error".
function addTurnNote(msg, text, kind) {
const note = document.createElement("div");
note.className = `turn-note ${kind}`;
note.textContent = text;
msg.appendChild(note);
}
async function uploadFiles(files) {
if (!files.length) return [];
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${location.origin}/gradio_api/upload`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return await res.json();
}
function setGenerating(generating) {
if (generating) {
sendBtn.textContent = "Stop";
sendBtn.classList.add("stop");
// Hide while generating; it reappears only once the turn is in history.
editLastBtn.hidden = true;
} else {
sendBtn.textContent = "Send";
sendBtn.classList.remove("stop");
currentJob = null;
}
}
sendBtn.addEventListener("click", async () => {
if (currentJob) {
cancelled = true;
currentJob.cancel();
setGenerating(false);
return;
}
const text = messageBox.value.trim();
const items = pendingFiles;
if (!text && !items.length) return;
if (!client) {
showBanner("Not connected to the server. Please reload the page.");
return;
}
const userEl = addUserMessage(text, items);
messageBox.value = "";
messageBox.style.height = "auto";
// Object URLs stay alive for the rendered bubble; just drop the pending list.
pendingFiles = [];
renderPreviews();
cancelled = false;
const myEpoch = ++turnEpoch;
setGenerating(true);
const els = addAssistantMessage();
// History length before this turn's entries; kept so the committed turn (below)
// can be taken back into the composer.
const historyLenBefore = history.length;
// Local files are uploaded; remote-URL items (examples) are passed straight
// to the model. Rebuild the list in the original order.
let chatFiles;
try {
const uploadedPaths = await uploadFiles(items.filter((i) => i.file).map((i) => i.file));
let next = 0;
chatFiles = items.map((i) => (i.file ? uploadedPaths[next++] : i.remoteUrl));
} catch (err) {
// If the conversation was reset during the upload, it's already cleared
// (and the reset reset the composer button); don't roll the discarded turn
// back into the composer or clobber a turn the user has since started.
if (myEpoch !== turnEpoch) return;
// Upload failed before the turn even started; roll it back so the user can retry.
removeTurn(userEl, els.msg);
loadComposer(text, items);
showBanner(`Upload failed: ${err.message}`);
setGenerating(false);
return;
}
// A reset may also have landed during a successful upload; if so, don't start
// generation for a conversation the user already cleared (the reset already
// restored the composer button, and a newer turn may now own it).
if (myEpoch !== turnEpoch) return;
const job = client.submit("/chat", {
text,
files: chatFiles,
history: history.slice(),
thinking: thinkingToggle.checked,
max_new_tokens: Number(maxTokensInput.value),
image_token_budget: Number(imageBudgetSelect.value),
system_prompt: systemPromptBox.value,
temperature: Number(temperatureInput.value),
top_p: Number(topPInput.value),
top_k: Number(topKInput.value),
repetition_penalty: Number(repPenaltyInput.value),
});
currentJob = job;
let lastContent = "";
// Set on failure (error status or a thrown error). The streamed content is
// left untouched so any partial answer survives; the detail is surfaced below.
let errorDetail = null;
try {
for await (const msg of job) {
if (msg.type === "data") {
const [out] = msg.data;
const stick = isNearBottom();
const reasoning = out?.reasoning ?? "";
const content = out?.content ?? "";
if (reasoning) {
els.reasoning.hidden = false;
renderAssistant(els.reasoningBody, reasoning);
}
if (content) {
lastContent = content;
renderAssistant(els.bubble, content);
}
if (stick) scrollToBottom();
} else if (msg.type === "unexpected_error" || (msg.type === "status" && (msg.stage ?? msg.status?.stage) === "error")) {
// Terminal failure (e.g. a raised gr.Error). The client closes the
// iterator on this event, so the for-await exits on its own.
errorDetail = msg.message ?? msg.status?.message ?? "Generation failed.";
}
}
} catch (err) {
errorDetail = err.message ?? "Generation failed.";
}
// If the conversation was reset (Clear chat) while this turn was still
// streaming, the reset already cleared its DOM, history, and composer. Bail
// out instead of resurrecting the discarded turn as ghost history or a stray
// "Edit last". A deliberate Stop does not bump the epoch, so that path still
// falls through below and keeps any partial answer.
if (myEpoch !== turnEpoch) return;
const hasContent = Boolean(lastContent);
const hasReasoning = !els.reasoning.hidden;
if (hasContent || hasReasoning) {
// Something worth keeping is on screen: a full/partial answer, or reasoning
// the user may want to read after stopping mid-thought. Keep it rather than
// yank the turn away.
if (hasContent) {
highlightCode(els.bubble);
addCopyButtons(els.bubble);
els.reasoning.open = false; // answer is the focus; fold the reasoning away
} else {
// Stopped/failed during reasoning with no answer: drop the empty answer
// bubble (just typing dots) but leave the reasoning expanded to read.
els.bubble.remove();
}
if (hasReasoning) highlightCode(els.reasoningBody);
// A stop is the user's own doing, so mark it quietly; a failure (e.g. a
// ZeroGPU timeout) is not, so call it out with a note and a banner.
if (cancelled) {
addTurnNote(els.msg, "⏹ Stopped", "stopped");
} else if (errorDetail) {
addTurnNote(els.msg, `⚠ ${errorDetail}`, "error");
showBanner(errorDetail);
}
// Keep the user message so a follow-up has context, but never push an empty
// assistant turn (it would feed the model a phantom answer). Reasoning is
// intentionally not stored, matching a normally finished turn.
history.push({ role: "user", text, files: chatFiles });
if (hasContent) history.push({ role: "assistant", text: lastContent, files: [] });
lastTurn = { text, items, userEl, assistantEl: els.msg, historyLenBefore };
editLastBtn.hidden = false; // turn is recorded; safe to offer take-back
} else {
// Nothing was rendered at all (stopped or failed before any output). Roll the
// turn back into the composer instead of leaving an empty bubble behind.
removeTurn(userEl, els.msg);
// Restore the input for a quick retry, unless the user has already started
// composing something else (don't clobber their draft).
if (!messageBox.value.trim() && !pendingFiles.length) loadComposer(text, items);
if (!cancelled && errorDetail) showBanner(errorDetail);
lastTurn = null;
editLastBtn.hidden = true;
}
setGenerating(false);
});
// Render the empty state (with examples) on first load.
showEmptyState();