AK391 Claude commited on
Commit
042a3c4
·
1 Parent(s): d103801

Stream thinking+answer live; restyle UI with BottleCap AI branding

Browse files

Backend (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>

Files changed (2) hide show
  1. app.py +43 -25
  2. 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
- from typing import Optional
 
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 literal markers
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 trailing partial tags / leftover special tokens for a clean answer."""
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
- """Generate one response from ThinkingCap-Qwen3.6-27B.
131
 
132
- Parameters mirror the model card's recommended sampling. Returns the raw
133
- text plus parsed thinking/answer and a thinking-token count, so the
134
- frontend can render a collapsible reasoning panel.
 
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
- prompt_len = inputs["input_ids"].shape[-1]
 
 
 
 
 
169
 
170
- with torch.inference_mode():
171
- out_ids = model.generate(
172
- **inputs,
173
- max_new_tokens=int(max_new_tokens),
174
- do_sample=float(temperature) > 0,
175
- temperature=max(float(temperature), 1e-5),
176
- top_p=float(top_p),
177
- top_k=int(top_k),
178
- )
 
 
 
 
 
 
 
 
 
179
 
180
- generated = out_ids[0, prompt_len:]
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
- return {
188
- "raw": full,
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
- --hf-yellow: #FFD21E;
10
- --hf-yellow-dark: #FFB400;
11
- --bg: #fafafa;
12
- --panel: #ffffff;
13
- --ink: #1f2937;
14
- --muted: #6b7280;
15
- --line: #e5e7eb;
16
- --user-bubble: #fff7d6;
17
- --user-border: #ffe78a;
18
- --assistant-bubble: #ffffff;
19
- --think-bg: #f3f4f6;
20
- --think-ink: #4b5563;
21
- --danger: #dc2626;
 
 
 
 
 
 
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(--panel);
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: 38px; height: 38px;
48
- background: var(--hf-yellow);
49
  border-radius: 10px;
50
- display: grid; place-items: center;
51
- font-size: 22px;
 
 
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(--muted);
59
- border: 1px solid var(--line); border-radius: 999px;
60
- padding: 4px 10px; background: var(--panel);
61
  }
62
  .icon-btn {
63
- border: 1px solid var(--line); background: var(--panel);
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: #f3f4f6; }
69
 
70
  /* Settings drawer */
71
  #settings {
72
  display: none;
73
- background: var(--panel);
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] { width: 90px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; }
 
 
 
 
86
  .switch { position: relative; width: 40px; height: 22px; }
87
  .switch input { opacity: 0; width: 0; height: 0; }
88
- .slider { position: absolute; inset: 0; background: #cbd5e1; border-radius: 999px; transition: .2s; cursor: pointer; }
89
- .slider:before { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; }
90
- .switch input:checked + .slider { background: var(--hf-yellow-dark); }
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(--hf-yellow); }
102
- .msg.assistant .avatar { background: #eef2ff; }
103
- .bubble { padding: 12px 14px; border-radius: var(--radius); border: 1px solid var(--line); max-width: 78%; word-wrap: break-word; overflow-wrap: anywhere; line-height: 1.5; font-size: 14.5px; }
104
- .msg.user .bubble { background: var(--user-bubble); border-color: var(--user-border); border-top-right-radius: 4px; }
105
- .msg.assistant .bubble { background: var(--assistant-bubble); border-top-left-radius: 4px; }
 
 
 
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(--hf-yellow-dark); font-variant-numeric: tabular-nums; }
116
  .answer { white-space: pre-wrap; }
117
 
118
- /* Pending / error */
119
- .pending .bubble { display: flex; align-items: center; gap: 10px; color: var(--muted); }
120
- .spinner { width: 14px; height: 14px; border: 2px solid #d1d5db; border-top-color: var(--hf-yellow-dark); border-radius: 50%; animation: spin .8s linear infinite; }
121
  @keyframes spin { to { transform: rotate(360deg); } }
122
  .elapsed { font-size: 12px; font-variant-numeric: tabular-nums; }
123
- .error .bubble { border-color: #fecaca; background: #fef2f2; color: var(--danger); }
 
 
 
 
124
 
125
  /* Empty state */
126
- #empty { text-align: center; color: var(--muted); padding: 60px 20px; max-width: 540px; margin: 0 auto; }
127
- #empty h2 { color: var(--ink); font-size: 19px; margin: 14px 0 6px; }
 
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(--panel); border-radius: 999px; cursor: pointer; color: var(--ink); }
131
- .examples button:hover { border-color: var(--hf-yellow-dark); background: #fffdf0; }
132
 
133
  /* Composer */
134
- footer { border-top: 1px solid var(--line); background: var(--panel); padding: 12px 18px; }
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(--panel); border-radius: 10px; width: 40px; height: 40px; cursor: pointer; font-size: 18px; color: var(--ink); display: grid; place-items: center; flex-shrink: 0; }
139
- .composer .btn:hover { background: #f3f4f6; }
140
- .composer .btn.primary { background: var(--hf-yellow); border-color: var(--hf-yellow-dark); color: #1f2937; font-weight: 700; }
141
- .composer .btn.primary:disabled { opacity: .5; cursor: not-allowed; }
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:focus { border-color: var(--hf-yellow-dark); }
144
- .img-chip { display: inline-flex; align-items: center; gap: 6px; background: #fff7d6; border: 1px solid var(--user-border); border-radius: 999px; padding: 4px 8px 4px 4px; font-size: 12px; margin-right: 8px; }
 
145
  .img-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
146
- .img-chip .x { cursor: pointer; color: var(--muted); font-weight: 700; }
147
- .hint { max-width: 820px; margin: 6px auto 0; font-size: 11px; color: var(--muted); text-align: center; }
 
 
 
 
 
148
  </style>
149
  </head>
150
  <body>
151
  <header>
152
- <div class="logo">🤗</div>
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)">emit &lt;think&gt; trace</span>
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 style="font-size:40px">🧠</div>
195
  <h2>Chat with ThinkingCap-Qwen3.6-27B</h2>
196
- <p>A finetune of Qwen 3.6 27B that keeps the answer quality while cutting thinking tokens by ~50% on average (and &gt;90% in best cases). Ask anything — reasoning, math, code, or upload an image.</p>
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
- // Conversation history sent to the backend (text-only; images are per-turn).
 
 
 
 
 
231
  let history = [];
232
  let client = null;
233
  let busy = false;
234
- let pendingFile = null; // File object selected for the next message
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 = "#16a34a";
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 => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[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 addPendingBubble() {
324
  const el = document.createElement("div");
325
  el.className = "msg assistant pending";
326
- el.id = "pending";
327
- el.innerHTML = `<div class="avatar">🧠</div>
328
- <div class="bubble"><span class="spinner"></span> thinking… <span class="elapsed" data-t0>0.0s</span></div>`;
 
 
 
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 finalizeBubble(pendingEl, data) {
343
- cancelAnimationFrame(pendingEl._timer);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  const { thinking, answer, thinking_tokens } = data;
345
  const thinkHtml = thinking
346
- ? `<details class="thinking" ${settings.enable ? "" : ""}>
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
- pendingEl.classList.remove("pending");
353
- pendingEl.querySelector(".bubble").innerHTML = thinkHtml + ansHtml;
354
  scrollDown();
355
  }
356
-
357
- function showError(pendingEl, msg) {
358
- cancelAnimationFrame(pendingEl._timer);
359
- pendingEl.classList.remove("pending");
360
- pendingEl.classList.add("error");
361
- pendingEl.querySelector(".bubble").innerHTML = "⚠️ " + escapeHtml(msg);
 
 
 
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 pendingEl = addPendingBubble();
 
 
395
  try {
396
- const result = await client.predict("/generate", payload);
397
- const data = result.data[0] || {};
398
- finalizeBubble(pendingEl, data);
399
- history.push({ role: "assistant", content: data.answer || data.raw || "" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  } catch (e) {
401
  console.error(e);
402
- showError(pendingEl, (e && e.message) ? e.message : "Generation failed.");
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 &gt;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 => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[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;