AK391 Claude commited on
Commit
9c5643a
·
1 Parent(s): 4500156

Add gradio.Server app for ThinkingCap-Qwen3.6-27B

Browse files

- app.py: gradio.Server backend with @app .api() generate endpoint,
ZeroGPU xlarge, bf16 model load at startup, Qwen3 thinking-trace parsing
- index.html: vanilla HTML/CSS/JS chat UI via Gradio JS Client with
collapsible reasoning panel + thinking-token counter
- requirements.txt + ZeroGPU-compatible python_version (3.12)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (4) hide show
  1. README.md +1 -1
  2. app.py +213 -0
  3. index.html +413 -0
  4. requirements.txt +5 -0
README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: yellow
5
  colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
 
5
  colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
+ python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  ---
app.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ThinkingCap-Qwen3.6-27B — Hugging Face Space
3
+ ============================================
4
+
5
+ Custom chat frontend (vanilla HTML/CSS/JS in index.html) talking to a
6
+ Gradio backend built with `gradio.Server`.
7
+
8
+ `gradio.Server` extends FastAPI. We expose one queued, ZeroGPU-backed
9
+ endpoint, `generate`, via `@app.api()` so it goes through Gradio's queue /
10
+ concurrency engine and is callable from the Gradio JS Client. A plain
11
+ `@app.get("/")` serves the static index.html.
12
+
13
+ Run locally: python app.py
14
+ On Spaces: select ZeroGPU hardware + xlarge GPU (96 GB) in Space settings
15
+ (see README). The 27B bf16 model needs the larger VRAM tier.
16
+ """
17
+
18
+ import os
19
+ import re
20
+ import sys
21
+ from typing import Optional
22
+
23
+ import spaces
24
+ import torch
25
+ from PIL import Image
26
+ from gradio import Server
27
+ from gradio.data_classes import FileData
28
+ from transformers import AutoModelForImageTextToText, AutoProcessor
29
+
30
+ MODEL_ID = os.environ.get("MODEL_ID", "bottlecapai/ThinkingCap-Qwen3.6-27B")
31
+
32
+ # Defaults mirror the model card's evaluation settings.
33
+ DEFAULT_MAX_NEW_TOKENS = 32768
34
+ DEFAULT_TEMPERATURE = 1.0
35
+ DEFAULT_TOP_P = 0.95
36
+ DEFAULT_TOP_K = 20
37
+
38
+ # Qwen3 reasoning-trace tags. Built by concatenation so the literal markers
39
+ # never appear together in source.
40
+ _THINK_OPEN = "<" + "think" + ">"
41
+ _THINK_CLOSE = "<" + "/think" + ">"
42
+ _THINK_RE = re.compile(
43
+ re.escape(_THINK_OPEN) + r"(.*?)" + re.escape(_THINK_CLOSE), re.DOTALL
44
+ )
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Model load (root level so ZeroGPU's CUDA emulation is active during startup;
48
+ # real CUDA is used inside @spaces.GPU).
49
+ # ---------------------------------------------------------------------------
50
+ print(f"[app] loading {MODEL_ID} ...", flush=True)
51
+ try:
52
+ processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
53
+ model = AutoModelForImageTextToText.from_pretrained(
54
+ MODEL_ID,
55
+ dtype=torch.bfloat16,
56
+ trust_remote_code=True,
57
+ )
58
+ model.to("cuda").eval()
59
+ print("[app] model ready.", flush=True)
60
+ except Exception as e: # pragma: no cover - surface clearly on Spaces logs
61
+ print(f"[app] FAILED to load model: {e}", file=sys.stderr, flush=True)
62
+ raise
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Backend
67
+ # ---------------------------------------------------------------------------
68
+ app = Server()
69
+
70
+
71
+ def _load_image(image: Optional[FileData]) -> Optional[Image.Image]:
72
+ """Accept a Gradio FileData (dict-like or object) and return a PIL image."""
73
+ if image is None:
74
+ return None
75
+ path = image["path"] if isinstance(image, dict) else image.path
76
+ if not path or not os.path.exists(path):
77
+ return None
78
+ return Image.open(path).convert("RGB")
79
+
80
+
81
+ def _build_messages(prompt: str, image: Optional[Image.Image]) -> list:
82
+ """Build a Qwen-VL style chat message list with an optional image."""
83
+ if image is None:
84
+ return [{"role": "user", "content": prompt}]
85
+ return [
86
+ {
87
+ "role": "user",
88
+ "content": [
89
+ {"type": "image", "image": image},
90
+ {"type": "text", "text": prompt},
91
+ ],
92
+ }
93
+ ]
94
+
95
+
96
+ def _split_thinking(text: str) -> tuple[str, str]:
97
+ """Split a Qwen3 reasoning trace into (thinking, answer)."""
98
+ matches = list(_THINK_RE.finditer(text))
99
+ if not matches:
100
+ return "", text.strip()
101
+ last = matches[-1]
102
+ thinking = last.group(1).strip()
103
+ answer = text[last.end():].strip()
104
+ if not answer:
105
+ answer = text[: matches[0].start()].strip()
106
+ return thinking, answer
107
+
108
+
109
+ def _tidy(text: str) -> str:
110
+ """Drop trailing partial tags / leftover special tokens for a clean answer."""
111
+ text = text.strip()
112
+ if _THINK_OPEN in text and _THINK_CLOSE not in text:
113
+ text = text.split(_THINK_OPEN, 1)[0].strip()
114
+ return text
115
+
116
+
117
+ @app.api()
118
+ @spaces.GPU(size="xlarge", duration=300)
119
+ def generate(
120
+ prompt: str,
121
+ image: Optional[FileData] = None,
122
+ history: Optional[list] = None,
123
+ max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS,
124
+ temperature: float = DEFAULT_TEMPERATURE,
125
+ top_p: float = DEFAULT_TOP_P,
126
+ top_k: int = DEFAULT_TOP_K,
127
+ enable_thinking: bool = True,
128
+ ) -> dict:
129
+ """Generate one response from ThinkingCap-Qwen3.6-27B.
130
+
131
+ Parameters mirror the model card's recommended sampling. Returns the raw
132
+ text plus parsed thinking/answer and a thinking-token count, so the
133
+ frontend can render a collapsible reasoning panel.
134
+ """
135
+ history = history or []
136
+ image_pil = _load_image(image)
137
+
138
+ messages: list = []
139
+ for turn in history:
140
+ role = turn.get("role", "user")
141
+ content = turn.get("content", "")
142
+ if role == "assistant":
143
+ _, ans = _split_thinking(content)
144
+ messages.append({"role": "assistant", "content": ans or content})
145
+ else:
146
+ messages.append({"role": "user", "content": content})
147
+ messages.append(_build_messages(prompt, image_pil)[0])
148
+
149
+ try:
150
+ text = processor.apply_chat_template(
151
+ messages,
152
+ tokenize=False,
153
+ add_generation_prompt=True,
154
+ enable_thinking=enable_thinking,
155
+ )
156
+ except TypeError:
157
+ text = processor.apply_chat_template(
158
+ messages, tokenize=False, add_generation_prompt=True
159
+ )
160
+
161
+ inputs = processor(
162
+ text=[text],
163
+ images=[image_pil] if image_pil is not None else None,
164
+ return_tensors="pt",
165
+ ).to(model.device)
166
+
167
+ prompt_len = inputs["input_ids"].shape[-1]
168
+
169
+ with torch.inference_mode():
170
+ out_ids = model.generate(
171
+ **inputs,
172
+ max_new_tokens=int(max_new_tokens),
173
+ do_sample=float(temperature) > 0,
174
+ temperature=max(float(temperature), 1e-5),
175
+ top_p=float(top_p),
176
+ top_k=int(top_k),
177
+ )
178
+
179
+ generated = out_ids[0, prompt_len:]
180
+ full = processor.tokenizer.decode(generated, skip_special_tokens=False)
181
+ full = _tidy(full)
182
+
183
+ thinking, answer = _split_thinking(full)
184
+ thinking_tokens = len(processor.tokenizer.encode(thinking)) if thinking else 0
185
+
186
+ return {
187
+ "raw": full,
188
+ "thinking": thinking,
189
+ "answer": answer,
190
+ "thinking_tokens": thinking_tokens,
191
+ }
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Static frontend
196
+ # ---------------------------------------------------------------------------
197
+ _STATIC_DIR = os.path.dirname(os.path.abspath(__file__))
198
+
199
+
200
+ @app.get("/")
201
+ async def homepage():
202
+ html_path = os.path.join(_STATIC_DIR, "index.html")
203
+ with open(html_path, "r", encoding="utf-8") as f:
204
+ return f.read()
205
+
206
+
207
+ @app.get("/health")
208
+ async def health():
209
+ return {"ok": True, "model": MODEL_ID}
210
+
211
+
212
+ if __name__ == "__main__":
213
+ app.launch(show_error=True, show_api=True)
index.html ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
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;
33
+ }
34
+
35
+ /* Header */
36
+ header {
37
+ display: flex;
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
+
94
+ /* Chat */
95
+ main { flex: 1; overflow-y: auto; padding: 22px 18px 10px; }
96
+ #messages { max-width: 820px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
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>
156
+ </div>
157
+ <div class="spacer"></div>
158
+ <span class="pill" id="status">connecting…</span>
159
+ <button class="icon-btn" id="gear" title="Settings">⚙️</button>
160
+ </header>
161
+
162
+ <div id="settings">
163
+ <div class="field">
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">
171
+ <label>Max new tokens</label>
172
+ <input type="number" id="s-max" value="32768" min="256" step="256" />
173
+ </div>
174
+ <div class="field">
175
+ <label>Temperature <span class="val" id="v-temp">1.0</span></label>
176
+ <div class="row"><input type="range" id="s-temp" min="0" max="2" step="0.05" value="1.0" /></div>
177
+ </div>
178
+ <div class="field">
179
+ <label>Top-p <span class="val" id="v-topp">0.95</span></label>
180
+ <div class="row"><input type="range" id="s-topp" min="0.1" max="1" step="0.01" value="0.95" /></div>
181
+ </div>
182
+ <div class="field">
183
+ <label>Top-k <span class="val" id="v-topk">20</span></label>
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>
200
+ <button>What's a good name for a token-efficient reasoning model?</button>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </main>
205
+
206
+ <footer>
207
+ <div class="composer">
208
+ <div class="attach">
209
+ <button class="btn" id="attach-btn" title="Attach image">📎</button>
210
+ <input type="file" id="file-input" accept="image/*" />
211
+ </div>
212
+ <textarea id="prompt" rows="1" placeholder="Message ThinkingCap… (Enter to send, Shift+Enter for newline)"></textarea>
213
+ <button class="btn primary" id="send" title="Send">➤</button>
214
+ </div>
215
+ <div class="hint" id="img-hint"></div>
216
+ </footer>
217
+
218
+ <script type="module">
219
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
220
+
221
+ const $ = (s) => document.querySelector(s);
222
+ const messagesEl = $("#messages");
223
+ const emptyEl = $("#empty");
224
+ const promptEl = $("#prompt");
225
+ const sendBtn = $("#send");
226
+ 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 };
238
+ const settings = { ...DEFAULTS };
239
+
240
+ function readSettings() {
241
+ settings.enable = $("#s-enable").checked;
242
+ settings.max = parseInt($("#s-max").value, 10) || DEFAULTS.max;
243
+ settings.temp = parseFloat($("#s-temp").value);
244
+ settings.topp = parseFloat($("#s-topp").value);
245
+ settings.topk = parseInt($("#s-topk").value, 10);
246
+ $("#v-temp").textContent = settings.temp.toFixed(2);
247
+ $("#v-topp").textContent = settings.topp.toFixed(2);
248
+ $("#v-topk").textContent = settings.topk;
249
+ }
250
+ ["s-enable","s-max","s-temp","s-topp","s-topk"].forEach(id => $("#" + id).addEventListener("input", readSettings));
251
+ $("#s-reset").addEventListener("click", () => {
252
+ $("#s-enable").checked = DEFAULTS.enable;
253
+ $("#s-max").value = DEFAULTS.max;
254
+ $("#s-temp").value = DEFAULTS.temp;
255
+ $("#s-topp").value = DEFAULTS.topp;
256
+ $("#s-topk").value = DEFAULTS.topk;
257
+ readSettings();
258
+ });
259
+ readSettings();
260
+
261
+ $("#gear").addEventListener("click", () => $("#settings").classList.toggle("open"));
262
+
263
+ // --- Connect to the Gradio backend ----------------------------------------
264
+ (async () => {
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
+ })();
275
+
276
+ // --- Composer behaviour ---------------------------------------------------
277
+ function autosize() {
278
+ promptEl.style.height = "auto";
279
+ promptEl.style.height = Math.min(promptEl.scrollHeight, 180) + "px";
280
+ }
281
+ promptEl.addEventListener("input", autosize);
282
+ promptEl.addEventListener("keydown", (e) => {
283
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
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];
291
+ if (!f) return;
292
+ pendingFile = f;
293
+ const url = URL.createObjectURL(f);
294
+ imgHint.innerHTML = `<span class="img-chip"><img src="${url}" />${escapeHtml(f.name)} <span class="x" id="rm-img">✕</span></span>`;
295
+ $("#rm-img").addEventListener("click", clearImage);
296
+ });
297
+ function clearImage() {
298
+ pendingFile = null;
299
+ fileInput.value = "";
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
+ );
307
+
308
+ // --- Render helpers --------------------------------------------------------
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();
315
+ const el = document.createElement("div");
316
+ el.className = "msg user";
317
+ const imgHtml = imgUrl ? `<img class="thumb" src="${imgUrl}" />` : "";
318
+ el.innerHTML = `<div class="avatar">🧑</div><div class="bubble">${imgHtml}${escapeHtml(text)}</div>`;
319
+ messagesEl.appendChild(el);
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
+ };
338
+ tick();
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;
370
+ busy = true;
371
+ sendBtn.disabled = true;
372
+
373
+ const imgUrl = pendingFile ? URL.createObjectURL(pendingFile) : null;
374
+ addUserBubble(text || "(image)", imgUrl);
375
+
376
+ const payload = {
377
+ prompt: text || "Describe this image.",
378
+ image: pendingFile ? handle_file(pendingFile) : null,
379
+ history,
380
+ max_new_tokens: settings.max,
381
+ temperature: settings.temp,
382
+ top_p: settings.topp,
383
+ top_k: settings.topk,
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;
407
+ sendBtn.disabled = false;
408
+ promptEl.focus();
409
+ }
410
+ }
411
+ </script>
412
+ </body>
413
+ </html>
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio==6.19.0
2
+ spaces>=0.36
3
+ transformers>=4.56
4
+ accelerate>=1.0
5
+ Pillow>=10.0