Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>ThinkingCap · Qwen 3.6 27B</title> | |
| <style> | |
| :root { | |
| /* BottleCap AI identity: the logo is pure black (#101010) on near-white | |
| (#f0f0f0). Dark, minimal, monochrome UI. */ | |
| --bc-mark: #101010; | |
| --bc-bg: #0e0e0f; | |
| --bc-surface: #18181b; | |
| --bc-surface-2: #1f1f23; | |
| --bc-line: #2a2a2e; | |
| --bc-ink: #f0f0f0; | |
| --bc-muted: #9a9aa0; | |
| --bc-faint: #6a6a70; | |
| --bc-accent: #2f2f33; /* monochrome "accent" (borders/hover) */ | |
| --bc-accent-strong: #e8e8e8; | |
| --bc-user-bubble: #202023; | |
| --bc-user-border: #2f2f33; | |
| --bc-assistant-bubble: #18181b; | |
| --bc-think-bg: #131316; | |
| --bc-think-ink: #9a9aa0; | |
| --bc-danger: #f87171; | |
| --bc-ok: #86efac; | |
| --radius: 14px; | |
| --logo: url("https://cdn-avatars.huggingface.co/v1/production/uploads/6410186e06c3b5ca88451747/suQ_-Q9WpitObx-oBv0i_.png"); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; margin: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| background: var(--bc-bg); | |
| color: var(--bc-ink); | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| /* Header */ | |
| header { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 12px 18px; | |
| background: var(--bc-surface); | |
| border-bottom: 1px solid var(--bc-line); | |
| z-index: 5; | |
| } | |
| .logo { | |
| width: 40px; height: 40px; | |
| background: #f0f0f0; | |
| border-radius: 10px; | |
| background-image: var(--logo); | |
| background-size: cover; | |
| background-position: center; | |
| flex-shrink: 0; | |
| } | |
| .titles { line-height: 1.2; } | |
| .titles h1 { margin: 0; font-size: 16px; font-weight: 700; color: var(--bc-ink); letter-spacing: -0.2px; } | |
| .titles p { margin: 2px 0 0; font-size: 12px; color: var(--bc-muted); } | |
| .spacer { flex: 1; } | |
| .pill { | |
| font-size: 11px; color: var(--bc-faint); | |
| border: 1px solid var(--bc-line); border-radius: 999px; | |
| padding: 4px 10px; background: var(--bc-surface-2); | |
| } | |
| .icon-btn { | |
| border: 1px solid var(--bc-line); background: var(--bc-surface-2); | |
| border-radius: 10px; width: 38px; height: 38px; | |
| cursor: pointer; font-size: 18px; color: var(--bc-ink); | |
| display: grid; place-items: center; | |
| } | |
| .icon-btn:hover { background: var(--bc-accent); } | |
| /* Settings drawer */ | |
| #settings { | |
| display: none; | |
| background: var(--bc-surface); | |
| border-bottom: 1px solid var(--bc-line); | |
| padding: 14px 18px; | |
| grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); | |
| gap: 14px 22px; | |
| } | |
| #settings.open { display: grid; } | |
| .field { display: flex; flex-direction: column; gap: 6px; } | |
| .field > label { font-size: 12px; font-weight: 600; color: var(--bc-muted); } | |
| .field .row { display: flex; align-items: center; gap: 8px; } | |
| .field input[type=range] { flex: 1; accent-color: var(--bc-accent-strong); } | |
| .field .val { font-size: 12px; min-width: 42px; text-align: right; color: var(--bc-ink); font-variant-numeric: tabular-nums; } | |
| .field input[type=number] { | |
| width: 90px; padding: 6px 8px; | |
| border: 1px solid var(--bc-line); border-radius: 8px; | |
| background: var(--bc-surface-2); color: var(--bc-ink); font-size: 13px; | |
| } | |
| .switch { position: relative; width: 40px; height: 22px; } | |
| .switch input { opacity: 0; width: 0; height: 0; } | |
| .slider { position: absolute; inset: 0; background: var(--bc-accent); border-radius: 999px; transition: .2s; cursor: pointer; } | |
| .slider:before { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #f0f0f0; border-radius: 50%; transition: .2s; } | |
| .switch input:checked + .slider { background: var(--bc-accent-strong); } | |
| .switch input:checked + .slider:before { transform: translateX(18px); } | |
| .reset-btn { align-self: end; } | |
| /* Chat */ | |
| main { flex: 1; overflow-y: auto; padding: 22px 18px 10px; } | |
| #messages { max-width: 820px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; } | |
| .msg { display: flex; gap: 10px; max-width: 100%; } | |
| .msg.user { flex-direction: row-reverse; } | |
| .avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center; font-size: 15px; overflow: hidden; } | |
| .msg.user .avatar { background: var(--bc-surface-2); color: var(--bc-ink); } | |
| .msg.assistant .avatar { | |
| background-image: var(--logo); background-size: cover; background-position: center; | |
| background-color: #f0f0f0; | |
| } | |
| .bubble { padding: 12px 14px; border-radius: var(--radius); border: 1px solid var(--bc-line); max-width: 78%; word-wrap: break-word; overflow-wrap: anywhere; line-height: 1.5; font-size: 14.5px; } | |
| .msg.user .bubble { background: var(--bc-user-bubble); border-color: var(--bc-user-border); border-top-right-radius: 4px; } | |
| .msg.assistant .bubble { background: var(--bc-assistant-bubble); border-top-left-radius: 4px; } | |
| .bubble img.thumb { max-width: 220px; max-height: 220px; border-radius: 8px; margin-bottom: 8px; display: block; } | |
| /* Reasoning panel */ | |
| .thinking { margin-bottom: 8px; border: 1px solid var(--bc-line); border-radius: 10px; background: var(--bc-think-bg); } | |
| .thinking > summary { cursor: pointer; padding: 8px 12px; font-size: 12.5px; font-weight: 600; color: var(--bc-think-ink); display: flex; align-items: center; gap: 8px; list-style: none; } | |
| .thinking > summary::-webkit-details-marker { display: none; } | |
| .thinking > summary .chev { transition: transform .15s; } | |
| .thinking[open] > summary .chev { transform: rotate(90deg); } | |
| .thinking .body { padding: 0 12px 10px; font-size: 13px; color: var(--bc-think-ink); white-space: pre-wrap; line-height: 1.55; } | |
| .tok-count { margin-left: auto; font-weight: 500; color: var(--bc-accent-strong); font-variant-numeric: tabular-nums; } | |
| .answer { white-space: pre-wrap; } | |
| /* Pending / streaming */ | |
| .pending .bubble { display: flex; align-items: center; gap: 10px; color: var(--bc-muted); } | |
| .spinner { width: 14px; height: 14px; border: 2px solid var(--bc-accent); border-top-color: var(--bc-accent-strong); border-radius: 50%; animation: spin .8s linear infinite; flex-shrink: 0; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .elapsed { font-size: 12px; font-variant-numeric: tabular-nums; } | |
| .live-think { font-size: 13px; color: var(--bc-think-ink); white-space: pre-wrap; line-height: 1.55; } | |
| .live-answer { white-space: pre-wrap; } | |
| .caret::after { content: "▍"; color: var(--bc-accent-strong); animation: blink 1s steps(1) infinite; margin-left: 1px; } | |
| @keyframes blink { 50% { opacity: 0; } } | |
| .error .bubble { border-color: #5b2a2a; background: #2a1516; color: var(--bc-danger); } | |
| /* Empty state */ | |
| #empty { text-align: center; color: var(--bc-muted); padding: 60px 20px; max-width: 540px; margin: 0 auto; } | |
| #empty .empty-logo { width: 64px; height: 64px; margin: 0 auto 14px; border-radius: 16px; background: #f0f0f0; background-image: var(--logo); background-size: cover; background-position: center; } | |
| #empty h2 { color: var(--bc-ink); font-size: 19px; margin: 0 0 6px; } | |
| #empty p { font-size: 14px; line-height: 1.55; } | |
| .examples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 18px; } | |
| .examples button { font: inherit; font-size: 13px; padding: 8px 12px; border: 1px solid var(--bc-line); background: var(--bc-surface-2); border-radius: 999px; cursor: pointer; color: var(--bc-ink); } | |
| .examples button:hover { border-color: var(--bc-accent-strong); background: var(--bc-accent); } | |
| /* Composer */ | |
| footer { border-top: 1px solid var(--bc-line); background: var(--bc-surface); padding: 12px 18px; } | |
| .composer { max-width: 820px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; } | |
| .composer .attach { position: relative; } | |
| .composer .attach input { display: none; } | |
| .composer .btn { border: 1px solid var(--bc-line); background: var(--bc-surface-2); border-radius: 10px; width: 40px; height: 40px; cursor: pointer; font-size: 18px; color: var(--bc-ink); display: grid; place-items: center; flex-shrink: 0; } | |
| .composer .btn:hover { background: var(--bc-accent); } | |
| .composer .btn.primary { background: var(--bc-mark); border-color: var(--bc-mark); color: #f0f0f0; font-weight: 700; } | |
| .composer .btn.primary:disabled { opacity: .4; cursor: not-allowed; } | |
| #prompt { flex: 1; resize: none; border: 1px solid var(--bc-line); border-radius: 10px; padding: 10px 12px; font: inherit; font-size: 14.5px; line-height: 1.4; max-height: 180px; outline: none; background: var(--bc-surface-2); color: var(--bc-ink); } | |
| #prompt::placeholder { color: var(--bc-faint); } | |
| #prompt:focus { border-color: var(--bc-accent-strong); } | |
| .img-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bc-user-bubble); border: 1px solid var(--bc-line); border-radius: 999px; padding: 4px 8px 4px 4px; font-size: 12px; margin-right: 8px; } | |
| .img-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; } | |
| .img-chip .x { cursor: pointer; color: var(--bc-faint); font-weight: 700; } | |
| .hint { max-width: 820px; margin: 6px auto 0; font-size: 11px; color: var(--bc-faint); text-align: center; } | |
| /* Scrollbar (dark) */ | |
| main::-webkit-scrollbar { width: 10px; } | |
| main::-webkit-scrollbar-thumb { background: var(--bc-accent); border-radius: 6px; border: 2px solid var(--bc-bg); } | |
| main::-webkit-scrollbar-track { background: transparent; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo" role="img" aria-label="BottleCap AI"></div> | |
| <div class="titles"> | |
| <h1>ThinkingCap · Qwen 3.6 27B</h1> | |
| <p>Token-efficient reasoning — same answers, ~50% fewer thinking tokens</p> | |
| </div> | |
| <div class="spacer"></div> | |
| <span class="pill" id="status">connecting…</span> | |
| <button class="icon-btn" id="gear" title="Settings">⚙️</button> | |
| </header> | |
| <div id="settings"> | |
| <div class="field"> | |
| <label>Thinking mode</label> | |
| <div class="row"> | |
| <label class="switch"><input type="checkbox" id="s-enable" checked /><span class="slider"></span></label> | |
| <span style="font-size:12px;color:var(--bc-muted)">stream the trace</span> | |
| </div> | |
| </div> | |
| <div class="field"> | |
| <label>Max new tokens</label> | |
| <input type="number" id="s-max" value="32768" min="256" step="256" /> | |
| </div> | |
| <div class="field"> | |
| <label>Temperature <span class="val" id="v-temp">1.0</span></label> | |
| <div class="row"><input type="range" id="s-temp" min="0" max="2" step="0.05" value="1.0" /></div> | |
| </div> | |
| <div class="field"> | |
| <label>Top-p <span class="val" id="v-topp">0.95</span></label> | |
| <div class="row"><input type="range" id="s-topp" min="0.1" max="1" step="0.01" value="0.95" /></div> | |
| </div> | |
| <div class="field"> | |
| <label>Top-k <span class="val" id="v-topk">20</span></label> | |
| <div class="row"><input type="range" id="s-topk" min="0" max="100" step="1" value="20" /></div> | |
| </div> | |
| <div class="field reset-btn"> | |
| <button class="btn" id="s-reset" style="width:auto;height:34px;padding:0 14px;font-size:13px;border:1px solid var(--bc-line);background:var(--bc-surface-2);color:var(--bc-ink);border-radius:8px;cursor:pointer;">Reset defaults</button> | |
| </div> | |
| </div> | |
| <main> | |
| <div id="messages"> | |
| <div id="empty"> | |
| <div class="empty-logo"></div> | |
| <h2>Chat with ThinkingCap-Qwen3.6-27B</h2> | |
| <p>A finetune of Qwen 3.6 27B that keeps answer quality while cutting thinking tokens by ~50% on average (and >90% in best cases). Ask anything — reasoning, math, code, or upload an image.</p> | |
| <div class="examples"> | |
| <button>Explain why 0.1 + 0.2 ≠ 0.3 in floating point</button> | |
| <button>Write a Python function to debounce calls</button> | |
| <button>What's a good name for a token-efficient reasoning model?</button> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <footer> | |
| <div class="composer"> | |
| <div class="attach"> | |
| <button class="btn" id="attach-btn" title="Attach image">📎</button> | |
| <input type="file" id="file-input" accept="image/*" /> | |
| </div> | |
| <textarea id="prompt" rows="1" placeholder="Message ThinkingCap… (Enter to send, Shift+Enter for newline)"></textarea> | |
| <button class="btn primary" id="send" title="Send">➤</button> | |
| </div> | |
| <div class="hint" id="img-hint"></div> | |
| </footer> | |
| <script type="module"> | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const $ = (s) => document.querySelector(s); | |
| const messagesEl = $("#messages"); | |
| const emptyEl = $("#empty"); | |
| const promptEl = $("#prompt"); | |
| const sendBtn = $("#send"); | |
| const fileInput = $("#file-input"); | |
| const statusEl = $("#status"); | |
| const imgHint = $("#img-hint"); | |
| // Reasoning-trace markers (built so the literal tags never sit together here). | |
| const THINK_OPEN = "<" + "think" + ">"; | |
| const THINK_CLOSE = "<" + "/think" + ">"; | |
| const IM_END = "<" + "|im_end|" + ">"; | |
| const EOT = "<" + "|endoftext|" + ">"; | |
| let history = []; | |
| let client = null; | |
| let busy = false; | |
| let pendingFile = null; | |
| // --- Settings -------------------------------------------------------------- | |
| const DEFAULTS = { enable: true, max: 32768, temp: 1.0, topp: 0.95, topk: 20 }; | |
| const settings = { ...DEFAULTS }; | |
| function readSettings() { | |
| settings.enable = $("#s-enable").checked; | |
| settings.max = parseInt($("#s-max").value, 10) || DEFAULTS.max; | |
| settings.temp = parseFloat($("#s-temp").value); | |
| settings.topp = parseFloat($("#s-topp").value); | |
| settings.topk = parseInt($("#s-topk").value, 10); | |
| $("#v-temp").textContent = settings.temp.toFixed(2); | |
| $("#v-topp").textContent = settings.topp.toFixed(2); | |
| $("#v-topk").textContent = settings.topk; | |
| } | |
| ["s-enable","s-max","s-temp","s-topp","s-topk"].forEach(id => $("#" + id).addEventListener("input", readSettings)); | |
| $("#s-reset").addEventListener("click", () => { | |
| $("#s-enable").checked = DEFAULTS.enable; | |
| $("#s-max").value = DEFAULTS.max; | |
| $("#s-temp").value = DEFAULTS.temp; | |
| $("#s-topp").value = DEFAULTS.topp; | |
| $("#s-topk").value = DEFAULTS.topk; | |
| readSettings(); | |
| }); | |
| readSettings(); | |
| $("#gear").addEventListener("click", () => $("#settings").classList.toggle("open")); | |
| // --- Connect to the Gradio backend ---------------------------------------- | |
| let connectError = null; | |
| async function connect() { | |
| try { | |
| client = await Client.connect(window.location.origin, { | |
| events: ["status", "data"], | |
| }); | |
| connectError = null; | |
| statusEl.textContent = "ready"; | |
| statusEl.style.color = "var(--bc-ok)"; | |
| } catch (e) { | |
| client = null; | |
| connectError = e; | |
| statusEl.textContent = "offline"; | |
| statusEl.style.color = "var(--bc-danger)"; | |
| console.error("Client.connect failed:", e); | |
| } | |
| } | |
| connect(); | |
| // --- Composer behaviour --------------------------------------------------- | |
| function autosize() { | |
| promptEl.style.height = "auto"; | |
| promptEl.style.height = Math.min(promptEl.scrollHeight, 180) + "px"; | |
| } | |
| promptEl.addEventListener("input", autosize); | |
| promptEl.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } | |
| }); | |
| sendBtn.addEventListener("click", send); | |
| $("#attach-btn").addEventListener("click", () => fileInput.click()); | |
| fileInput.addEventListener("change", () => { | |
| const f = fileInput.files[0]; | |
| if (!f) return; | |
| pendingFile = f; | |
| const url = URL.createObjectURL(f); | |
| imgHint.innerHTML = `<span class="img-chip"><img src="${url}" />${escapeHtml(f.name)} <span class="x" id="rm-img">✕</span></span>`; | |
| $("#rm-img").addEventListener("click", clearImage); | |
| }); | |
| function clearImage() { | |
| pendingFile = null; | |
| fileInput.value = ""; | |
| imgHint.innerHTML = ""; | |
| } | |
| document.querySelectorAll(".examples button").forEach(b => | |
| b.addEventListener("click", () => { promptEl.value = b.textContent; autosize(); promptEl.focus(); }) | |
| ); | |
| // --- Render helpers -------------------------------------------------------- | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, c => ({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c])); | |
| } | |
| function stripMarkers(s) { | |
| return s | |
| .split(IM_END).join("") | |
| .split(EOT).join("") | |
| .split(THINK_OPEN).join("") | |
| .split(THINK_CLOSE).join(""); | |
| } | |
| // Split accumulated text into a live (thinking, answer) view. | |
| function splitLive(text) { | |
| let think = "", answer = "", state = "answer"; | |
| const o = text.indexOf(THINK_OPEN); | |
| const c = text.indexOf(THINK_CLOSE); | |
| if (o === -1 && c === -1) { | |
| // No thinking block at all so far → treat everything as answer. | |
| return { thinking: "", answer: text }; | |
| } | |
| if (o !== -1) { | |
| state = "think"; | |
| think = text.slice(o + THINK_OPEN.length); | |
| if (c !== -1 && c > o) { | |
| think = text.slice(o + THINK_OPEN.length, c); | |
| answer = text.slice(c + THINK_CLOSE.length); | |
| state = "answer"; | |
| } | |
| } | |
| return { thinking: think, answer, state }; | |
| } | |
| function addUserBubble(text, imgUrl) { | |
| if (emptyEl) emptyEl.remove(); | |
| const el = document.createElement("div"); | |
| el.className = "msg user"; | |
| const imgHtml = imgUrl ? `<img class="thumb" src="${imgUrl}" />` : ""; | |
| el.innerHTML = `<div class="avatar">🧑</div><div class="bubble">${imgHtml}${escapeHtml(text)}</div>`; | |
| messagesEl.appendChild(el); | |
| scrollDown(); | |
| } | |
| function addStreamingBubble() { | |
| const el = document.createElement("div"); | |
| el.className = "msg assistant pending"; | |
| el.innerHTML = `<div class="avatar"></div> | |
| <div class="bubble"> | |
| <span class="spinner"></span> thinking… | |
| <span class="elapsed" data-t0>0.0s</span> | |
| <div class="live"></div> | |
| </div>`; | |
| messagesEl.appendChild(el); | |
| scrollDown(); | |
| const t0 = performance.now(); | |
| const tick = () => { | |
| const node = el.querySelector("[data-t0]"); | |
| if (!node || el.dataset.done) return; | |
| node.textContent = ((performance.now() - t0) / 1000).toFixed(1) + "s"; | |
| el._timer = requestAnimationFrame(tick); | |
| }; | |
| tick(); | |
| return el; | |
| } | |
| function updateLive(el, text) { | |
| const live = el.querySelector(".live"); | |
| const head = el.querySelector(".bubble"); | |
| const { thinking, answer, state } = splitLive(text); | |
| if (thinking || state === "think") { | |
| head.querySelector(".spinner").style.display = ""; | |
| head.querySelector(".elapsed").style.display = ""; | |
| live.innerHTML = `<div class="live-think caret">${escapeHtml(thinking)}</div>`; | |
| } else if (answer) { | |
| head.querySelector(".spinner").style.display = "none"; | |
| head.querySelector(".elapsed").style.display = "none"; | |
| live.innerHTML = `<div class="live-answer caret">${escapeHtml(answer)}</div>`; | |
| } | |
| scrollDown(); | |
| } | |
| function finalizeBubble(el, data) { | |
| el.dataset.done = "1"; | |
| cancelAnimationFrame(el._timer); | |
| const { thinking, answer, thinking_tokens } = data; | |
| const thinkHtml = thinking | |
| ? `<details class="thinking" ${stateOpen(el) ? "open" : ""}> | |
| <summary><span class="chev">▸</span> Reasoning <span class="tok-count">${thinking_tokens} thinking tokens</span></summary> | |
| <div class="body">${escapeHtml(thinking)}</div> | |
| </details>` | |
| : ""; | |
| const ansHtml = answer ? `<div class="answer">${escapeHtml(answer)}</div>` : ""; | |
| el.classList.remove("pending"); | |
| el.querySelector(".bubble").innerHTML = thinkHtml + ansHtml; | |
| scrollDown(); | |
| } | |
| // Keep the reasoning panel open across finalize if the user expanded it while streaming. | |
| function stateOpen(el) { return el.querySelector(".live-think") && el.dataset.userOpen === "1"; } | |
| function showError(el, msg) { | |
| el.dataset.done = "1"; | |
| cancelAnimationFrame(el._timer); | |
| el.classList.remove("pending"); | |
| el.classList.add("error"); | |
| el.querySelector(".bubble").innerHTML = "⚠️ " + escapeHtml(msg); | |
| } | |
| function scrollDown() { messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight; } | |
| // --- Send (streaming via Gradio JS client) -------------------------------- | |
| async function send() { | |
| const text = promptEl.value.trim(); | |
| if (!text && !pendingFile) return; | |
| if (busy) return; | |
| if (!client) { | |
| // Try (re)connecting once before bailing out, so a cold load still works. | |
| statusEl.textContent = "reconnecting…"; | |
| await connect(); | |
| if (!client) { | |
| showErrorInline(connectError ? String(connectError.message || connectError) : "not connected to backend"); | |
| return; | |
| } | |
| } | |
| busy = true; | |
| sendBtn.disabled = true; | |
| const imgUrl = pendingFile ? URL.createObjectURL(pendingFile) : null; | |
| addUserBubble(text || "(image)", imgUrl); | |
| const payload = { | |
| prompt: text || "Describe this image.", | |
| image: pendingFile ? handle_file(pendingFile) : null, | |
| history, | |
| max_new_tokens: settings.max, | |
| temperature: settings.temp, | |
| top_p: settings.topp, | |
| top_k: settings.topk, | |
| enable_thinking: settings.enable, | |
| }; | |
| history.push({ role: "user", content: text || "(image)" }); | |
| promptEl.value = ""; | |
| clearImage(); | |
| autosize(); | |
| const el = addStreamingBubble(); | |
| let accumulated = ""; | |
| let finalized = false; | |
| let lastData = null; | |
| try { | |
| // submit() returns an async-iterable job for streaming endpoints. | |
| const job = client.submit("/generate", payload); | |
| for await (const message of job) { | |
| if (message.type === "data" && message.data) { | |
| const chunk = message.data[0]; | |
| if (chunk && typeof chunk.text === "string") { | |
| accumulated = chunk.text; // server echoes the full text so far | |
| lastData = chunk; | |
| updateLive(el, accumulated); | |
| } | |
| } else if (message.type === "status") { | |
| const st = message.status || (message.data && message.data.status); | |
| if (st === "error") { | |
| throw new Error("Backend returned an error status"); | |
| } | |
| } | |
| } | |
| // If the server yielded a final dict with parsed fields, use it; | |
| // otherwise split the accumulated text on the client. | |
| const finalChunk = lastData || {}; | |
| finalizeBubble(el, { | |
| thinking: finalChunk.thinking || (finalChunk.text ? splitLive(finalChunk.text).thinking : ""), | |
| answer: finalChunk.answer || finalChunk.text || accumulated || "", | |
| thinking_tokens: finalChunk.thinking_tokens || 0, | |
| }); | |
| finalized = true; | |
| history.push({ role: "assistant", content: finalChunk.answer || finalChunk.text || accumulated || "" }); | |
| } catch (e) { | |
| console.error(e); | |
| if (!finalized) { | |
| showError(el, (e && e.message) ? e.message : "Generation failed."); | |
| history.pop(); | |
| } | |
| } finally { | |
| busy = false; | |
| sendBtn.disabled = false; | |
| promptEl.focus(); | |
| } | |
| } | |
| // Inline error when not connected (no bubble yet). | |
| function showErrorInline(msg) { | |
| const el = document.createElement("div"); | |
| el.className = "msg assistant error"; | |
| el.innerHTML = `<div class="avatar"></div><div class="bubble">⚠️ ${escapeHtml(msg || "error")}</div>`; | |
| if (emptyEl) emptyEl.remove(); | |
| messagesEl.appendChild(el); | |
| scrollDown(); | |
| } | |
| </script> | |
| </body> | |
| </html> |