Spaces:
Runtime error
Runtime error
AK391 Claude commited on
Commit ·
042a3c4
1
Parent(s): d103801
Stream thinking+answer live; restyle UI with BottleCap AI branding
Browse filesBackend (app.py):
- generate() is now a generator yielding {"text": ...} as tokens stream via
TextIteratorStreamer, then a final dict with parsed thinking/answer + token
count. The reasoning trace streams live, not just the answer.
Frontend (index.html):
- BottleCap AI dark monochrome palette (logo is pure #101010 on #f0f0f0).
- BottleCap AI logo image in header, assistant avatar, and empty state.
- Live streaming render: shows the trace as it grows, then finalizes
into a collapsible Reasoning panel + answer with token count.
Co-Authored-By: Claude <noreply@anthropic.com>
- app.py +43 -25
- index.html +196 -107
app.py
CHANGED
|
@@ -18,7 +18,8 @@ On Spaces: select ZeroGPU hardware + xlarge GPU (96 GB) in Space settings
|
|
| 18 |
import os
|
| 19 |
import re
|
| 20 |
import sys
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
import spaces
|
| 24 |
import torch
|
|
@@ -27,6 +28,7 @@ from fastapi.responses import HTMLResponse
|
|
| 27 |
from gradio import Server
|
| 28 |
from gradio.data_classes import FileData
|
| 29 |
from transformers import AutoModelForImageTextToText, AutoProcessor
|
|
|
|
| 30 |
|
| 31 |
MODEL_ID = os.environ.get("MODEL_ID", "bottlecapai/ThinkingCap-Qwen3.6-27B")
|
| 32 |
|
|
@@ -36,13 +38,15 @@ DEFAULT_TEMPERATURE = 1.0
|
|
| 36 |
DEFAULT_TOP_P = 0.95
|
| 37 |
DEFAULT_TOP_K = 20
|
| 38 |
|
| 39 |
-
# Qwen3 reasoning-trace tags. Built by concatenation so the
|
| 40 |
-
# never appear together in source.
|
| 41 |
_THINK_OPEN = "<" + "think" + ">"
|
| 42 |
_THINK_CLOSE = "<" + "/think" + ">"
|
| 43 |
_THINK_RE = re.compile(
|
| 44 |
re.escape(_THINK_OPEN) + r"(.*?)" + re.escape(_THINK_CLOSE), re.DOTALL
|
| 45 |
)
|
|
|
|
|
|
|
| 46 |
|
| 47 |
# ---------------------------------------------------------------------------
|
| 48 |
# Model load (root level so ZeroGPU's CUDA emulation is active during startup;
|
|
@@ -108,8 +112,8 @@ def _split_thinking(text: str) -> tuple[str, str]:
|
|
| 108 |
|
| 109 |
|
| 110 |
def _tidy(text: str) -> str:
|
| 111 |
-
"""Drop
|
| 112 |
-
text = text.strip()
|
| 113 |
if _THINK_OPEN in text and _THINK_CLOSE not in text:
|
| 114 |
text = text.split(_THINK_OPEN, 1)[0].strip()
|
| 115 |
return text
|
|
@@ -126,12 +130,13 @@ def generate(
|
|
| 126 |
top_p: float = DEFAULT_TOP_P,
|
| 127 |
top_k: int = DEFAULT_TOP_K,
|
| 128 |
enable_thinking: bool = True,
|
| 129 |
-
) -> dict:
|
| 130 |
-
"""
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
| 135 |
"""
|
| 136 |
history = history or []
|
| 137 |
image_pil = _load_image(image)
|
|
@@ -165,27 +170,40 @@ def generate(
|
|
| 165 |
return_tensors="pt",
|
| 166 |
).to(model.device)
|
| 167 |
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
| 181 |
-
full = processor.tokenizer.decode(generated, skip_special_tokens=False)
|
| 182 |
-
full = _tidy(full)
|
| 183 |
|
|
|
|
| 184 |
thinking, answer = _split_thinking(full)
|
| 185 |
thinking_tokens = len(processor.tokenizer.encode(thinking)) if thinking else 0
|
| 186 |
|
| 187 |
-
|
| 188 |
-
"
|
| 189 |
"thinking": thinking,
|
| 190 |
"answer": answer,
|
| 191 |
"thinking_tokens": thinking_tokens,
|
|
|
|
| 18 |
import os
|
| 19 |
import re
|
| 20 |
import sys
|
| 21 |
+
import threading
|
| 22 |
+
from typing import Iterator, Optional
|
| 23 |
|
| 24 |
import spaces
|
| 25 |
import torch
|
|
|
|
| 28 |
from gradio import Server
|
| 29 |
from gradio.data_classes import FileData
|
| 30 |
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 31 |
+
from transformers import TextIteratorStreamer
|
| 32 |
|
| 33 |
MODEL_ID = os.environ.get("MODEL_ID", "bottlecapai/ThinkingCap-Qwen3.6-27B")
|
| 34 |
|
|
|
|
| 38 |
DEFAULT_TOP_P = 0.95
|
| 39 |
DEFAULT_TOP_K = 20
|
| 40 |
|
| 41 |
+
# Qwen3 reasoning-trace tags + EOS markers. Built by concatenation so the
|
| 42 |
+
# literal markers never appear together in source.
|
| 43 |
_THINK_OPEN = "<" + "think" + ">"
|
| 44 |
_THINK_CLOSE = "<" + "/think" + ">"
|
| 45 |
_THINK_RE = re.compile(
|
| 46 |
re.escape(_THINK_OPEN) + r"(.*?)" + re.escape(_THINK_CLOSE), re.DOTALL
|
| 47 |
)
|
| 48 |
+
_IM_END = "<" + "|im_end|" + ">"
|
| 49 |
+
_EOT = "<" + "|endoftext|" + ">"
|
| 50 |
|
| 51 |
# ---------------------------------------------------------------------------
|
| 52 |
# Model load (root level so ZeroGPU's CUDA emulation is active during startup;
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
def _tidy(text: str) -> str:
|
| 115 |
+
"""Drop leftover special tokens (eos) and an unclosed think block."""
|
| 116 |
+
text = text.replace(_IM_END, "").replace(_EOT, "").strip()
|
| 117 |
if _THINK_OPEN in text and _THINK_CLOSE not in text:
|
| 118 |
text = text.split(_THINK_OPEN, 1)[0].strip()
|
| 119 |
return text
|
|
|
|
| 130 |
top_p: float = DEFAULT_TOP_P,
|
| 131 |
top_k: int = DEFAULT_TOP_K,
|
| 132 |
enable_thinking: bool = True,
|
| 133 |
+
) -> Iterator[dict]:
|
| 134 |
+
"""Stream a response from ThinkingCap-Qwen3.6-27B.
|
| 135 |
|
| 136 |
+
Yields ``{"text": <full text so far>}`` dicts as tokens are produced
|
| 137 |
+
(so the frontend can render the reasoning trace live), then a final dict
|
| 138 |
+
with parsed ``thinking`` / ``answer`` and a thinking-token count.
|
| 139 |
+
Parameters mirror the model card's recommended sampling.
|
| 140 |
"""
|
| 141 |
history = history or []
|
| 142 |
image_pil = _load_image(image)
|
|
|
|
| 170 |
return_tensors="pt",
|
| 171 |
).to(model.device)
|
| 172 |
|
| 173 |
+
streamer = TextIteratorStreamer(
|
| 174 |
+
processor.tokenizer,
|
| 175 |
+
skip_prompt=True,
|
| 176 |
+
skip_special_tokens=False,
|
| 177 |
+
timeout=60.0,
|
| 178 |
+
)
|
| 179 |
|
| 180 |
+
gen_kwargs = dict(
|
| 181 |
+
**inputs,
|
| 182 |
+
max_new_tokens=int(max_new_tokens),
|
| 183 |
+
do_sample=float(temperature) > 0,
|
| 184 |
+
temperature=max(float(temperature), 1e-5),
|
| 185 |
+
top_p=float(top_p),
|
| 186 |
+
top_k=int(top_k),
|
| 187 |
+
streamer=streamer,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
thread = threading.Thread(target=lambda: model.generate(**gen_kwargs))
|
| 191 |
+
thread.start()
|
| 192 |
+
|
| 193 |
+
accumulated = ""
|
| 194 |
+
for piece in streamer:
|
| 195 |
+
accumulated += piece
|
| 196 |
+
# Echo the full text-so-far so the client can render the live trace.
|
| 197 |
+
yield {"text": accumulated}
|
| 198 |
|
| 199 |
+
thread.join()
|
|
|
|
|
|
|
| 200 |
|
| 201 |
+
full = _tidy(accumulated)
|
| 202 |
thinking, answer = _split_thinking(full)
|
| 203 |
thinking_tokens = len(processor.tokenizer.encode(thinking)) if thinking else 0
|
| 204 |
|
| 205 |
+
yield {
|
| 206 |
+
"text": full,
|
| 207 |
"thinking": thinking,
|
| 208 |
"answer": answer,
|
| 209 |
"thinking_tokens": thinking_tokens,
|
index.html
CHANGED
|
@@ -6,27 +6,34 @@
|
|
| 6 |
<title>ThinkingCap · Qwen 3.6 27B</title>
|
| 7 |
<style>
|
| 8 |
:root {
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--line: #
|
| 16 |
-
--
|
| 17 |
-
--
|
| 18 |
-
--
|
| 19 |
-
--
|
| 20 |
-
--
|
| 21 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
--radius: 14px;
|
|
|
|
| 23 |
}
|
| 24 |
* { box-sizing: border-box; }
|
| 25 |
html, body { height: 100%; margin: 0; }
|
| 26 |
body {
|
| 27 |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 28 |
-
background: var(--bg);
|
| 29 |
-
color: var(--ink);
|
| 30 |
display: flex;
|
| 31 |
flex-direction: column;
|
| 32 |
height: 100vh;
|
|
@@ -38,56 +45,61 @@
|
|
| 38 |
align-items: center;
|
| 39 |
gap: 12px;
|
| 40 |
padding: 12px 18px;
|
| 41 |
-
background: var(--
|
| 42 |
-
border-bottom: 1px solid var(--line);
|
| 43 |
-
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
|
| 44 |
z-index: 5;
|
| 45 |
}
|
| 46 |
.logo {
|
| 47 |
-
width:
|
| 48 |
-
background:
|
| 49 |
border-radius: 10px;
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
.titles { line-height: 1.2; }
|
| 54 |
-
.titles h1 { margin: 0; font-size: 16px; font-weight: 700; }
|
| 55 |
-
.titles p { margin: 2px 0 0; font-size: 12px; color: var(--muted); }
|
| 56 |
.spacer { flex: 1; }
|
| 57 |
.pill {
|
| 58 |
-
font-size: 11px; color: var(--
|
| 59 |
-
border: 1px solid var(--line); border-radius: 999px;
|
| 60 |
-
padding: 4px 10px; background: var(--
|
| 61 |
}
|
| 62 |
.icon-btn {
|
| 63 |
-
border: 1px solid var(--line); background: var(--
|
| 64 |
border-radius: 10px; width: 38px; height: 38px;
|
| 65 |
-
cursor: pointer; font-size: 18px; color: var(--ink);
|
| 66 |
display: grid; place-items: center;
|
| 67 |
}
|
| 68 |
-
.icon-btn:hover { background:
|
| 69 |
|
| 70 |
/* Settings drawer */
|
| 71 |
#settings {
|
| 72 |
display: none;
|
| 73 |
-
background: var(--
|
| 74 |
-
border-bottom: 1px solid var(--line);
|
| 75 |
padding: 14px 18px;
|
| 76 |
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 77 |
gap: 14px 22px;
|
| 78 |
}
|
| 79 |
#settings.open { display: grid; }
|
| 80 |
.field { display: flex; flex-direction: column; gap: 6px; }
|
| 81 |
-
.field label { font-size: 12px; font-weight: 600; color: var(--muted); }
|
| 82 |
.field .row { display: flex; align-items: center; gap: 8px; }
|
| 83 |
-
.field input[type=range] { flex: 1; }
|
| 84 |
-
.field .val { font-size: 12px; min-width: 42px; text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
|
| 85 |
-
.field input[type=number] {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
.switch { position: relative; width: 40px; height: 22px; }
|
| 87 |
.switch input { opacity: 0; width: 0; height: 0; }
|
| 88 |
-
.slider { position: absolute; inset: 0; background:
|
| 89 |
-
.slider:before { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #
|
| 90 |
-
.switch input:checked + .slider { background: var(--
|
| 91 |
.switch input:checked + .slider:before { transform: translateX(18px); }
|
| 92 |
.reset-btn { align-self: end; }
|
| 93 |
|
|
@@ -97,59 +109,73 @@
|
|
| 97 |
|
| 98 |
.msg { display: flex; gap: 10px; max-width: 100%; }
|
| 99 |
.msg.user { flex-direction: row-reverse; }
|
| 100 |
-
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center; font-size: 15px; }
|
| 101 |
-
.msg.user .avatar { background: var(--
|
| 102 |
-
.msg.assistant .avatar {
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
.bubble img.thumb { max-width: 220px; max-height: 220px; border-radius: 8px; margin-bottom: 8px; display: block; }
|
| 107 |
|
| 108 |
/* Reasoning panel */
|
| 109 |
-
.thinking { margin-bottom: 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--think-bg); }
|
| 110 |
-
.thinking summary { cursor: pointer; padding: 8px 12px; font-size: 12.5px; font-weight: 600; color: var(--think-ink); display: flex; align-items: center; gap: 8px; list-style: none; }
|
| 111 |
-
.thinking summary::-webkit-details-marker { display: none; }
|
| 112 |
-
.thinking summary .chev { transition: transform .15s; }
|
| 113 |
-
.thinking[open] summary .chev { transform: rotate(90deg); }
|
| 114 |
-
.thinking .body { padding: 0 12px 10px; font-size: 13px; color: var(--think-ink); white-space: pre-wrap; line-height: 1.55; }
|
| 115 |
-
.tok-count { margin-left: auto; font-weight: 500; color: var(--
|
| 116 |
.answer { white-space: pre-wrap; }
|
| 117 |
|
| 118 |
-
/* Pending /
|
| 119 |
-
.pending .bubble { display: flex; align-items: center; gap: 10px; color: var(--muted); }
|
| 120 |
-
.spinner { width: 14px; height: 14px; border: 2px solid
|
| 121 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 122 |
.elapsed { font-size: 12px; font-variant-numeric: tabular-nums; }
|
| 123 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
/* Empty state */
|
| 126 |
-
#empty { text-align: center; color: var(--muted); padding: 60px 20px; max-width: 540px; margin: 0 auto; }
|
| 127 |
-
#empty
|
|
|
|
| 128 |
#empty p { font-size: 14px; line-height: 1.55; }
|
| 129 |
.examples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 18px; }
|
| 130 |
-
.examples button { font: inherit; font-size: 13px; padding: 8px 12px; border: 1px solid var(--line); background: var(--
|
| 131 |
-
.examples button:hover { border-color: var(--
|
| 132 |
|
| 133 |
/* Composer */
|
| 134 |
-
footer { border-top: 1px solid var(--line); background: var(--
|
| 135 |
.composer { max-width: 820px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
|
| 136 |
.composer .attach { position: relative; }
|
| 137 |
.composer .attach input { display: none; }
|
| 138 |
-
.composer .btn { border: 1px solid var(--line); background: var(--
|
| 139 |
-
.composer .btn:hover { background:
|
| 140 |
-
.composer .btn.primary { background: var(--
|
| 141 |
-
.composer .btn.primary:disabled { opacity: .
|
| 142 |
-
#prompt { flex: 1; resize: none; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font: inherit; font-size: 14.5px; line-height: 1.4; max-height: 180px; outline: none; }
|
| 143 |
-
#prompt:
|
| 144 |
-
|
|
|
|
| 145 |
.img-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
|
| 146 |
-
.img-chip .x { cursor: pointer; color: var(--
|
| 147 |
-
.hint { max-width: 820px; margin: 6px auto 0; font-size: 11px; color: var(--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
</style>
|
| 149 |
</head>
|
| 150 |
<body>
|
| 151 |
<header>
|
| 152 |
-
<div class="logo">
|
| 153 |
<div class="titles">
|
| 154 |
<h1>ThinkingCap · Qwen 3.6 27B</h1>
|
| 155 |
<p>Token-efficient reasoning — same answers, ~50% fewer thinking tokens</p>
|
|
@@ -164,7 +190,7 @@
|
|
| 164 |
<label>Thinking mode</label>
|
| 165 |
<div class="row">
|
| 166 |
<label class="switch"><input type="checkbox" id="s-enable" checked /><span class="slider"></span></label>
|
| 167 |
-
<span style="font-size:12px;color:var(--muted)">
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
<div class="field">
|
|
@@ -184,16 +210,16 @@
|
|
| 184 |
<div class="row"><input type="range" id="s-topk" min="0" max="100" step="1" value="20" /></div>
|
| 185 |
</div>
|
| 186 |
<div class="field reset-btn">
|
| 187 |
-
<button class="btn" id="s-reset" style="width:auto;height:34px;padding:0 14px;font-size:13px;">Reset defaults</button>
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
|
| 191 |
<main>
|
| 192 |
<div id="messages">
|
| 193 |
<div id="empty">
|
| 194 |
-
<div
|
| 195 |
<h2>Chat with ThinkingCap-Qwen3.6-27B</h2>
|
| 196 |
-
<p>A finetune of Qwen 3.6 27B that keeps
|
| 197 |
<div class="examples">
|
| 198 |
<button>Explain why 0.1 + 0.2 ≠ 0.3 in floating point</button>
|
| 199 |
<button>Write a Python function to debounce calls</button>
|
|
@@ -227,11 +253,16 @@ const fileInput = $("#file-input");
|
|
| 227 |
const statusEl = $("#status");
|
| 228 |
const imgHint = $("#img-hint");
|
| 229 |
|
| 230 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
let history = [];
|
| 232 |
let client = null;
|
| 233 |
let busy = false;
|
| 234 |
-
let pendingFile = null;
|
| 235 |
|
| 236 |
// --- Settings --------------------------------------------------------------
|
| 237 |
const DEFAULTS = { enable: true, max: 32768, temp: 1.0, topp: 0.95, topk: 20 };
|
|
@@ -265,10 +296,10 @@ $("#gear").addEventListener("click", () => $("#settings").classList.toggle("open
|
|
| 265 |
try {
|
| 266 |
client = await Client.connect(window.location.origin);
|
| 267 |
statusEl.textContent = "ready";
|
| 268 |
-
statusEl.style.color = "
|
| 269 |
} catch (e) {
|
| 270 |
statusEl.textContent = "offline";
|
| 271 |
-
statusEl.style.color = "var(--danger)";
|
| 272 |
console.error(e);
|
| 273 |
}
|
| 274 |
})();
|
|
@@ -284,7 +315,6 @@ promptEl.addEventListener("keydown", (e) => {
|
|
| 284 |
});
|
| 285 |
sendBtn.addEventListener("click", send);
|
| 286 |
|
| 287 |
-
// Image attach
|
| 288 |
$("#attach-btn").addEventListener("click", () => fileInput.click());
|
| 289 |
fileInput.addEventListener("change", () => {
|
| 290 |
const f = fileInput.files[0];
|
|
@@ -300,7 +330,6 @@ function clearImage() {
|
|
| 300 |
imgHint.innerHTML = "";
|
| 301 |
}
|
| 302 |
|
| 303 |
-
// Example chips
|
| 304 |
document.querySelectorAll(".examples button").forEach(b =>
|
| 305 |
b.addEventListener("click", () => { promptEl.value = b.textContent; autosize(); promptEl.focus(); })
|
| 306 |
);
|
|
@@ -309,6 +338,29 @@ document.querySelectorAll(".examples button").forEach(b =>
|
|
| 309 |
function escapeHtml(s) {
|
| 310 |
return String(s).replace(/[&<>"']/g, c => ({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c]));
|
| 311 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
function addUserBubble(text, imgUrl) {
|
| 314 |
if (emptyEl) emptyEl.remove();
|
|
@@ -320,18 +372,21 @@ function addUserBubble(text, imgUrl) {
|
|
| 320 |
scrollDown();
|
| 321 |
}
|
| 322 |
|
| 323 |
-
function
|
| 324 |
const el = document.createElement("div");
|
| 325 |
el.className = "msg assistant pending";
|
| 326 |
-
el.
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
| 329 |
messagesEl.appendChild(el);
|
| 330 |
scrollDown();
|
| 331 |
const t0 = performance.now();
|
| 332 |
const tick = () => {
|
| 333 |
const node = el.querySelector("[data-t0]");
|
| 334 |
-
if (!node) return;
|
| 335 |
node.textContent = ((performance.now() - t0) / 1000).toFixed(1) + "s";
|
| 336 |
el._timer = requestAnimationFrame(tick);
|
| 337 |
};
|
|
@@ -339,31 +394,51 @@ function addPendingBubble() {
|
|
| 339 |
return el;
|
| 340 |
}
|
| 341 |
|
| 342 |
-
function
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
const { thinking, answer, thinking_tokens } = data;
|
| 345 |
const thinkHtml = thinking
|
| 346 |
-
? `<details class="thinking" ${
|
| 347 |
<summary><span class="chev">▸</span> Reasoning <span class="tok-count">${thinking_tokens} thinking tokens</span></summary>
|
| 348 |
<div class="body">${escapeHtml(thinking)}</div>
|
| 349 |
</details>`
|
| 350 |
: "";
|
| 351 |
const ansHtml = answer ? `<div class="answer">${escapeHtml(answer)}</div>` : "";
|
| 352 |
-
|
| 353 |
-
|
| 354 |
scrollDown();
|
| 355 |
}
|
| 356 |
-
|
| 357 |
-
function
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
| 362 |
}
|
| 363 |
|
| 364 |
function scrollDown() { messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight; }
|
| 365 |
|
| 366 |
-
// --- Send --------------------------------
|
| 367 |
async function send() {
|
| 368 |
const text = promptEl.value.trim();
|
| 369 |
if ((!text && !pendingFile) || busy || !client) return;
|
|
@@ -384,23 +459,37 @@ async function send() {
|
|
| 384 |
enable_thinking: settings.enable,
|
| 385 |
};
|
| 386 |
|
| 387 |
-
// Record the user turn in history (text only).
|
| 388 |
history.push({ role: "user", content: text || "(image)" });
|
| 389 |
-
|
| 390 |
promptEl.value = "";
|
| 391 |
clearImage();
|
| 392 |
autosize();
|
| 393 |
|
| 394 |
-
const
|
|
|
|
|
|
|
| 395 |
try {
|
| 396 |
-
const
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
} catch (e) {
|
| 401 |
console.error(e);
|
| 402 |
-
showError(
|
| 403 |
-
// Roll back the user turn we optimistically added.
|
| 404 |
history.pop();
|
| 405 |
} finally {
|
| 406 |
busy = false;
|
|
|
|
| 6 |
<title>ThinkingCap · Qwen 3.6 27B</title>
|
| 7 |
<style>
|
| 8 |
:root {
|
| 9 |
+
/* BottleCap AI identity: the logo is pure black (#101010) on near-white
|
| 10 |
+
(#f0f0f0). Dark, minimal, monochrome UI. */
|
| 11 |
+
--bc-mark: #101010;
|
| 12 |
+
--bc-bg: #0e0e0f;
|
| 13 |
+
--bc-surface: #18181b;
|
| 14 |
+
--bc-surface-2: #1f1f23;
|
| 15 |
+
--bc-line: #2a2a2e;
|
| 16 |
+
--bc-ink: #f0f0f0;
|
| 17 |
+
--bc-muted: #9a9aa0;
|
| 18 |
+
--bc-faint: #6a6a70;
|
| 19 |
+
--bc-accent: #2f2f33; /* monochrome "accent" (borders/hover) */
|
| 20 |
+
--bc-accent-strong: #e8e8e8;
|
| 21 |
+
--bc-user-bubble: #202023;
|
| 22 |
+
--bc-user-border: #2f2f33;
|
| 23 |
+
--bc-assistant-bubble: #18181b;
|
| 24 |
+
--bc-think-bg: #131316;
|
| 25 |
+
--bc-think-ink: #9a9aa0;
|
| 26 |
+
--bc-danger: #f87171;
|
| 27 |
+
--bc-ok: #86efac;
|
| 28 |
--radius: 14px;
|
| 29 |
+
--logo: url("https://cdn-avatars.huggingface.co/v1/production/uploads/6410186e06c3b5ca88451747/suQ_-Q9WpitObx-oBv0i_.png");
|
| 30 |
}
|
| 31 |
* { box-sizing: border-box; }
|
| 32 |
html, body { height: 100%; margin: 0; }
|
| 33 |
body {
|
| 34 |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 35 |
+
background: var(--bc-bg);
|
| 36 |
+
color: var(--bc-ink);
|
| 37 |
display: flex;
|
| 38 |
flex-direction: column;
|
| 39 |
height: 100vh;
|
|
|
|
| 45 |
align-items: center;
|
| 46 |
gap: 12px;
|
| 47 |
padding: 12px 18px;
|
| 48 |
+
background: var(--bc-surface);
|
| 49 |
+
border-bottom: 1px solid var(--bc-line);
|
|
|
|
| 50 |
z-index: 5;
|
| 51 |
}
|
| 52 |
.logo {
|
| 53 |
+
width: 40px; height: 40px;
|
| 54 |
+
background: #f0f0f0;
|
| 55 |
border-radius: 10px;
|
| 56 |
+
background-image: var(--logo);
|
| 57 |
+
background-size: cover;
|
| 58 |
+
background-position: center;
|
| 59 |
+
flex-shrink: 0;
|
| 60 |
}
|
| 61 |
.titles { line-height: 1.2; }
|
| 62 |
+
.titles h1 { margin: 0; font-size: 16px; font-weight: 700; color: var(--bc-ink); letter-spacing: -0.2px; }
|
| 63 |
+
.titles p { margin: 2px 0 0; font-size: 12px; color: var(--bc-muted); }
|
| 64 |
.spacer { flex: 1; }
|
| 65 |
.pill {
|
| 66 |
+
font-size: 11px; color: var(--bc-faint);
|
| 67 |
+
border: 1px solid var(--bc-line); border-radius: 999px;
|
| 68 |
+
padding: 4px 10px; background: var(--bc-surface-2);
|
| 69 |
}
|
| 70 |
.icon-btn {
|
| 71 |
+
border: 1px solid var(--bc-line); background: var(--bc-surface-2);
|
| 72 |
border-radius: 10px; width: 38px; height: 38px;
|
| 73 |
+
cursor: pointer; font-size: 18px; color: var(--bc-ink);
|
| 74 |
display: grid; place-items: center;
|
| 75 |
}
|
| 76 |
+
.icon-btn:hover { background: var(--bc-accent); }
|
| 77 |
|
| 78 |
/* Settings drawer */
|
| 79 |
#settings {
|
| 80 |
display: none;
|
| 81 |
+
background: var(--bc-surface);
|
| 82 |
+
border-bottom: 1px solid var(--bc-line);
|
| 83 |
padding: 14px 18px;
|
| 84 |
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 85 |
gap: 14px 22px;
|
| 86 |
}
|
| 87 |
#settings.open { display: grid; }
|
| 88 |
.field { display: flex; flex-direction: column; gap: 6px; }
|
| 89 |
+
.field > label { font-size: 12px; font-weight: 600; color: var(--bc-muted); }
|
| 90 |
.field .row { display: flex; align-items: center; gap: 8px; }
|
| 91 |
+
.field input[type=range] { flex: 1; accent-color: var(--bc-accent-strong); }
|
| 92 |
+
.field .val { font-size: 12px; min-width: 42px; text-align: right; color: var(--bc-ink); font-variant-numeric: tabular-nums; }
|
| 93 |
+
.field input[type=number] {
|
| 94 |
+
width: 90px; padding: 6px 8px;
|
| 95 |
+
border: 1px solid var(--bc-line); border-radius: 8px;
|
| 96 |
+
background: var(--bc-surface-2); color: var(--bc-ink); font-size: 13px;
|
| 97 |
+
}
|
| 98 |
.switch { position: relative; width: 40px; height: 22px; }
|
| 99 |
.switch input { opacity: 0; width: 0; height: 0; }
|
| 100 |
+
.slider { position: absolute; inset: 0; background: var(--bc-accent); border-radius: 999px; transition: .2s; cursor: pointer; }
|
| 101 |
+
.slider:before { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #f0f0f0; border-radius: 50%; transition: .2s; }
|
| 102 |
+
.switch input:checked + .slider { background: var(--bc-accent-strong); }
|
| 103 |
.switch input:checked + .slider:before { transform: translateX(18px); }
|
| 104 |
.reset-btn { align-self: end; }
|
| 105 |
|
|
|
|
| 109 |
|
| 110 |
.msg { display: flex; gap: 10px; max-width: 100%; }
|
| 111 |
.msg.user { flex-direction: row-reverse; }
|
| 112 |
+
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center; font-size: 15px; overflow: hidden; }
|
| 113 |
+
.msg.user .avatar { background: var(--bc-surface-2); color: var(--bc-ink); }
|
| 114 |
+
.msg.assistant .avatar {
|
| 115 |
+
background-image: var(--logo); background-size: cover; background-position: center;
|
| 116 |
+
background-color: #f0f0f0;
|
| 117 |
+
}
|
| 118 |
+
.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; }
|
| 119 |
+
.msg.user .bubble { background: var(--bc-user-bubble); border-color: var(--bc-user-border); border-top-right-radius: 4px; }
|
| 120 |
+
.msg.assistant .bubble { background: var(--bc-assistant-bubble); border-top-left-radius: 4px; }
|
| 121 |
.bubble img.thumb { max-width: 220px; max-height: 220px; border-radius: 8px; margin-bottom: 8px; display: block; }
|
| 122 |
|
| 123 |
/* Reasoning panel */
|
| 124 |
+
.thinking { margin-bottom: 8px; border: 1px solid var(--bc-line); border-radius: 10px; background: var(--bc-think-bg); }
|
| 125 |
+
.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; }
|
| 126 |
+
.thinking > summary::-webkit-details-marker { display: none; }
|
| 127 |
+
.thinking > summary .chev { transition: transform .15s; }
|
| 128 |
+
.thinking[open] > summary .chev { transform: rotate(90deg); }
|
| 129 |
+
.thinking .body { padding: 0 12px 10px; font-size: 13px; color: var(--bc-think-ink); white-space: pre-wrap; line-height: 1.55; }
|
| 130 |
+
.tok-count { margin-left: auto; font-weight: 500; color: var(--bc-accent-strong); font-variant-numeric: tabular-nums; }
|
| 131 |
.answer { white-space: pre-wrap; }
|
| 132 |
|
| 133 |
+
/* Pending / streaming */
|
| 134 |
+
.pending .bubble { display: flex; align-items: center; gap: 10px; color: var(--bc-muted); }
|
| 135 |
+
.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; }
|
| 136 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 137 |
.elapsed { font-size: 12px; font-variant-numeric: tabular-nums; }
|
| 138 |
+
.live-think { font-size: 13px; color: var(--bc-think-ink); white-space: pre-wrap; line-height: 1.55; }
|
| 139 |
+
.live-answer { white-space: pre-wrap; }
|
| 140 |
+
.caret::after { content: "▍"; color: var(--bc-accent-strong); animation: blink 1s steps(1) infinite; margin-left: 1px; }
|
| 141 |
+
@keyframes blink { 50% { opacity: 0; } }
|
| 142 |
+
.error .bubble { border-color: #5b2a2a; background: #2a1516; color: var(--bc-danger); }
|
| 143 |
|
| 144 |
/* Empty state */
|
| 145 |
+
#empty { text-align: center; color: var(--bc-muted); padding: 60px 20px; max-width: 540px; margin: 0 auto; }
|
| 146 |
+
#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; }
|
| 147 |
+
#empty h2 { color: var(--bc-ink); font-size: 19px; margin: 0 0 6px; }
|
| 148 |
#empty p { font-size: 14px; line-height: 1.55; }
|
| 149 |
.examples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 18px; }
|
| 150 |
+
.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); }
|
| 151 |
+
.examples button:hover { border-color: var(--bc-accent-strong); background: var(--bc-accent); }
|
| 152 |
|
| 153 |
/* Composer */
|
| 154 |
+
footer { border-top: 1px solid var(--bc-line); background: var(--bc-surface); padding: 12px 18px; }
|
| 155 |
.composer { max-width: 820px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
|
| 156 |
.composer .attach { position: relative; }
|
| 157 |
.composer .attach input { display: none; }
|
| 158 |
+
.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; }
|
| 159 |
+
.composer .btn:hover { background: var(--bc-accent); }
|
| 160 |
+
.composer .btn.primary { background: var(--bc-mark); border-color: var(--bc-mark); color: #f0f0f0; font-weight: 700; }
|
| 161 |
+
.composer .btn.primary:disabled { opacity: .4; cursor: not-allowed; }
|
| 162 |
+
#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); }
|
| 163 |
+
#prompt::placeholder { color: var(--bc-faint); }
|
| 164 |
+
#prompt:focus { border-color: var(--bc-accent-strong); }
|
| 165 |
+
.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; }
|
| 166 |
.img-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
|
| 167 |
+
.img-chip .x { cursor: pointer; color: var(--bc-faint); font-weight: 700; }
|
| 168 |
+
.hint { max-width: 820px; margin: 6px auto 0; font-size: 11px; color: var(--bc-faint); text-align: center; }
|
| 169 |
+
|
| 170 |
+
/* Scrollbar (dark) */
|
| 171 |
+
main::-webkit-scrollbar { width: 10px; }
|
| 172 |
+
main::-webkit-scrollbar-thumb { background: var(--bc-accent); border-radius: 6px; border: 2px solid var(--bc-bg); }
|
| 173 |
+
main::-webkit-scrollbar-track { background: transparent; }
|
| 174 |
</style>
|
| 175 |
</head>
|
| 176 |
<body>
|
| 177 |
<header>
|
| 178 |
+
<div class="logo" role="img" aria-label="BottleCap AI"></div>
|
| 179 |
<div class="titles">
|
| 180 |
<h1>ThinkingCap · Qwen 3.6 27B</h1>
|
| 181 |
<p>Token-efficient reasoning — same answers, ~50% fewer thinking tokens</p>
|
|
|
|
| 190 |
<label>Thinking mode</label>
|
| 191 |
<div class="row">
|
| 192 |
<label class="switch"><input type="checkbox" id="s-enable" checked /><span class="slider"></span></label>
|
| 193 |
+
<span style="font-size:12px;color:var(--bc-muted)">stream the trace</span>
|
| 194 |
</div>
|
| 195 |
</div>
|
| 196 |
<div class="field">
|
|
|
|
| 210 |
<div class="row"><input type="range" id="s-topk" min="0" max="100" step="1" value="20" /></div>
|
| 211 |
</div>
|
| 212 |
<div class="field reset-btn">
|
| 213 |
+
<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>
|
| 214 |
</div>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
<main>
|
| 218 |
<div id="messages">
|
| 219 |
<div id="empty">
|
| 220 |
+
<div class="empty-logo"></div>
|
| 221 |
<h2>Chat with ThinkingCap-Qwen3.6-27B</h2>
|
| 222 |
+
<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>
|
| 223 |
<div class="examples">
|
| 224 |
<button>Explain why 0.1 + 0.2 ≠ 0.3 in floating point</button>
|
| 225 |
<button>Write a Python function to debounce calls</button>
|
|
|
|
| 253 |
const statusEl = $("#status");
|
| 254 |
const imgHint = $("#img-hint");
|
| 255 |
|
| 256 |
+
// Reasoning-trace markers (built so the literal tags never sit together here).
|
| 257 |
+
const THINK_OPEN = "<" + "think" + ">";
|
| 258 |
+
const THINK_CLOSE = "<" + "/think" + ">";
|
| 259 |
+
const IM_END = "<" + "|im_end|" + ">";
|
| 260 |
+
const EOT = "<" + "|endoftext|" + ">";
|
| 261 |
+
|
| 262 |
let history = [];
|
| 263 |
let client = null;
|
| 264 |
let busy = false;
|
| 265 |
+
let pendingFile = null;
|
| 266 |
|
| 267 |
// --- Settings --------------------------------------------------------------
|
| 268 |
const DEFAULTS = { enable: true, max: 32768, temp: 1.0, topp: 0.95, topk: 20 };
|
|
|
|
| 296 |
try {
|
| 297 |
client = await Client.connect(window.location.origin);
|
| 298 |
statusEl.textContent = "ready";
|
| 299 |
+
statusEl.style.color = "var(--bc-ok)";
|
| 300 |
} catch (e) {
|
| 301 |
statusEl.textContent = "offline";
|
| 302 |
+
statusEl.style.color = "var(--bc-danger)";
|
| 303 |
console.error(e);
|
| 304 |
}
|
| 305 |
})();
|
|
|
|
| 315 |
});
|
| 316 |
sendBtn.addEventListener("click", send);
|
| 317 |
|
|
|
|
| 318 |
$("#attach-btn").addEventListener("click", () => fileInput.click());
|
| 319 |
fileInput.addEventListener("change", () => {
|
| 320 |
const f = fileInput.files[0];
|
|
|
|
| 330 |
imgHint.innerHTML = "";
|
| 331 |
}
|
| 332 |
|
|
|
|
| 333 |
document.querySelectorAll(".examples button").forEach(b =>
|
| 334 |
b.addEventListener("click", () => { promptEl.value = b.textContent; autosize(); promptEl.focus(); })
|
| 335 |
);
|
|
|
|
| 338 |
function escapeHtml(s) {
|
| 339 |
return String(s).replace(/[&<>"']/g, c => ({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c]));
|
| 340 |
}
|
| 341 |
+
function stripMarkers(s) {
|
| 342 |
+
return s.split(IM_END).join("").split(EOT).join("").replace(/</?think>/g, "");
|
| 343 |
+
}
|
| 344 |
+
// Split accumulated text into a live (thinking, answer) view.
|
| 345 |
+
function splitLive(text) {
|
| 346 |
+
let think = "", answer = "", state = "answer";
|
| 347 |
+
const o = text.indexOf(THINK_OPEN);
|
| 348 |
+
const c = text.indexOf(THINK_CLOSE);
|
| 349 |
+
if (o === -1 && c === -1) {
|
| 350 |
+
// No thinking block at all so far → treat everything as answer.
|
| 351 |
+
return { thinking: "", answer: text };
|
| 352 |
+
}
|
| 353 |
+
if (o !== -1) {
|
| 354 |
+
state = "think";
|
| 355 |
+
think = text.slice(o + THINK_OPEN.length);
|
| 356 |
+
if (c !== -1 && c > o) {
|
| 357 |
+
think = text.slice(o + THINK_OPEN.length, c);
|
| 358 |
+
answer = text.slice(c + THINK_CLOSE.length);
|
| 359 |
+
state = "answer";
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
return { thinking: think, answer, state };
|
| 363 |
+
}
|
| 364 |
|
| 365 |
function addUserBubble(text, imgUrl) {
|
| 366 |
if (emptyEl) emptyEl.remove();
|
|
|
|
| 372 |
scrollDown();
|
| 373 |
}
|
| 374 |
|
| 375 |
+
function addStreamingBubble() {
|
| 376 |
const el = document.createElement("div");
|
| 377 |
el.className = "msg assistant pending";
|
| 378 |
+
el.innerHTML = `<div class="avatar"></div>
|
| 379 |
+
<div class="bubble">
|
| 380 |
+
<span class="spinner"></span> thinking…
|
| 381 |
+
<span class="elapsed" data-t0>0.0s</span>
|
| 382 |
+
<div class="live"></div>
|
| 383 |
+
</div>`;
|
| 384 |
messagesEl.appendChild(el);
|
| 385 |
scrollDown();
|
| 386 |
const t0 = performance.now();
|
| 387 |
const tick = () => {
|
| 388 |
const node = el.querySelector("[data-t0]");
|
| 389 |
+
if (!node || el.dataset.done) return;
|
| 390 |
node.textContent = ((performance.now() - t0) / 1000).toFixed(1) + "s";
|
| 391 |
el._timer = requestAnimationFrame(tick);
|
| 392 |
};
|
|
|
|
| 394 |
return el;
|
| 395 |
}
|
| 396 |
|
| 397 |
+
function updateLive(el, text) {
|
| 398 |
+
const live = el.querySelector(".live");
|
| 399 |
+
const head = el.querySelector(".bubble");
|
| 400 |
+
const { thinking, answer, state } = splitLive(text);
|
| 401 |
+
if (thinking || state === "think") {
|
| 402 |
+
head.querySelector(".spinner").style.display = "";
|
| 403 |
+
head.querySelector(".elapsed").style.display = "";
|
| 404 |
+
live.innerHTML = `<div class="live-think caret">${escapeHtml(thinking)}</div>`;
|
| 405 |
+
} else if (answer) {
|
| 406 |
+
head.querySelector(".spinner").style.display = "none";
|
| 407 |
+
head.querySelector(".elapsed").style.display = "none";
|
| 408 |
+
live.innerHTML = `<div class="live-answer caret">${escapeHtml(answer)}</div>`;
|
| 409 |
+
}
|
| 410 |
+
scrollDown();
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
function finalizeBubble(el, data) {
|
| 414 |
+
el.dataset.done = "1";
|
| 415 |
+
cancelAnimationFrame(el._timer);
|
| 416 |
const { thinking, answer, thinking_tokens } = data;
|
| 417 |
const thinkHtml = thinking
|
| 418 |
+
? `<details class="thinking" ${stateOpen(el) ? "open" : ""}>
|
| 419 |
<summary><span class="chev">▸</span> Reasoning <span class="tok-count">${thinking_tokens} thinking tokens</span></summary>
|
| 420 |
<div class="body">${escapeHtml(thinking)}</div>
|
| 421 |
</details>`
|
| 422 |
: "";
|
| 423 |
const ansHtml = answer ? `<div class="answer">${escapeHtml(answer)}</div>` : "";
|
| 424 |
+
el.classList.remove("pending");
|
| 425 |
+
el.querySelector(".bubble").innerHTML = thinkHtml + ansHtml;
|
| 426 |
scrollDown();
|
| 427 |
}
|
| 428 |
+
// Keep the reasoning panel open across finalize if the user expanded it while streaming.
|
| 429 |
+
function stateOpen(el) { return el.querySelector(".live-think") && el.dataset.userOpen === "1"; }
|
| 430 |
+
|
| 431 |
+
function showError(el, msg) {
|
| 432 |
+
el.dataset.done = "1";
|
| 433 |
+
cancelAnimationFrame(el._timer);
|
| 434 |
+
el.classList.remove("pending");
|
| 435 |
+
el.classList.add("error");
|
| 436 |
+
el.querySelector(".bubble").innerHTML = "⚠️ " + escapeHtml(msg);
|
| 437 |
}
|
| 438 |
|
| 439 |
function scrollDown() { messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight; }
|
| 440 |
|
| 441 |
+
// --- Send (streaming via Gradio JS client) --------------------------------
|
| 442 |
async function send() {
|
| 443 |
const text = promptEl.value.trim();
|
| 444 |
if ((!text && !pendingFile) || busy || !client) return;
|
|
|
|
| 459 |
enable_thinking: settings.enable,
|
| 460 |
};
|
| 461 |
|
|
|
|
| 462 |
history.push({ role: "user", content: text || "(image)" });
|
|
|
|
| 463 |
promptEl.value = "";
|
| 464 |
clearImage();
|
| 465 |
autosize();
|
| 466 |
|
| 467 |
+
const el = addStreamingBubble();
|
| 468 |
+
let accumulated = "";
|
| 469 |
+
|
| 470 |
try {
|
| 471 |
+
const stream = await client.predict("/generate", payload, { events: ["generating", "done"] });
|
| 472 |
+
|
| 473 |
+
for await (const msg of stream) {
|
| 474 |
+
if (msg.type === "generating" && msg.data) {
|
| 475 |
+
const chunk = msg.data[0];
|
| 476 |
+
if (chunk && typeof chunk.text === "string") {
|
| 477 |
+
accumulated = chunk.text; // server echoes full text so far
|
| 478 |
+
updateLive(el, accumulated);
|
| 479 |
+
}
|
| 480 |
+
} else if (msg.type === "done") {
|
| 481 |
+
const data = (msg.data && msg.data[0]) || {};
|
| 482 |
+
finalizeBubble(el, {
|
| 483 |
+
thinking: data.thinking || "",
|
| 484 |
+
answer: data.answer || data.text || "",
|
| 485 |
+
thinking_tokens: data.thinking_tokens || 0,
|
| 486 |
+
});
|
| 487 |
+
history.push({ role: "assistant", content: data.answer || data.text || "" });
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
} catch (e) {
|
| 491 |
console.error(e);
|
| 492 |
+
showError(el, (e && e.message) ? e.message : "Generation failed.");
|
|
|
|
| 493 |
history.pop();
|
| 494 |
} finally {
|
| 495 |
busy = false;
|