akhaliq HF Staff GLM-5.2 commited on
Commit
ddcbb6b
·
1 Parent(s): 9cbc561

Add gradio.Server chat demo for Ornith-1.0-9B

Browse files

gradio.Server backend (app.py) lazy-loads the multimodal model under
@spaces.GPU and exposes a streaming @app .api("generate") endpoint; a custom
index.html chat UI (served at GET /) consumes it via the Gradio JS client
with image upload, a collapsible reasoning panel, and markdown answers.

Co-Authored-By: GLM-5.2 <noreply@z.ai>

Files changed (4) hide show
  1. README.md +15 -0
  2. app.py +323 -0
  3. index.html +364 -0
  4. requirements.txt +7 -0
README.md CHANGED
@@ -10,4 +10,19 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
10
  pinned: false
11
  ---
12
 
13
+ # Ornith-1.0-9B — gradio.Server demo
14
+
15
+ A chat Space for [`deepreinforce-ai/Ornith-1.0-9B`](https://huggingface.co/deepreinforce-ai/Ornith-1.0-9B),
16
+ a multimodal (image-text-to-text) reasoning model.
17
+
18
+ Built with [`gradio.Server`](https://www.gradio.app/guides/server-mode):
19
+ `app.py` exposes the model as a streaming `@app.api()` endpoint, while a custom
20
+ `index.html` chat UI (served at `GET /`) talks to it via the Gradio JS client.
21
+ The chain-of-thought streams into a collapsible **Reasoning** panel; the final
22
+ answer renders below it (with markdown).
23
+
24
+ - Multimodal: attach an image and ask about it, or chat with text only.
25
+ - Recommended sampling from the model card: `temperature=0.6`, `top_p=0.95`, `top_k=20`.
26
+ - `@spaces.GPU` handles ZeroGPU allocation on Spaces.
27
+
28
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ornith-1.0-9B — a gradio.Server chat app.
3
+
4
+ This Space demonstrates `deepreinforce-ai/Ornith-1.0-9B`, a multimodal
5
+ (qwen3.5-based) reasoning model, served through `gradio.Server`.
6
+
7
+ `gradio.Server` extends FastAPI and adds Gradio's API engine on top
8
+ (queuing, concurrency control, SSE streaming, gradio_client compatibility).
9
+ Instead of Gradio's component UI we ship a custom HTML/CSS/JS chat page
10
+ (see `index.html`) served at `GET /`, while the model inference is exposed
11
+ as a streaming `@app.api()` endpoint at `/gradio_api/call/generate`.
12
+
13
+ Run locally (needs a CUDA GPU + transformers >= 5.8.1):
14
+ python app.py
15
+ On a Hugging Face ZeroGPU Space, `@spaces.GPU` handles device allocation.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from threading import Thread
21
+ from typing import Optional
22
+
23
+ import torch
24
+ from PIL import Image
25
+ from transformers import (
26
+ AutoModelForMultimodalLM,
27
+ AutoProcessor,
28
+ TextIteratorStreamer,
29
+ )
30
+
31
+ from fastapi.responses import HTMLResponse
32
+ from gradio import Server
33
+ from gradio.data_classes import FileData
34
+
35
+ # `spaces` only exists on Hugging Face Spaces. Guard the import so the file
36
+ # still parses/imports on a plain machine (the GPU decorator becomes a no-op).
37
+ try:
38
+ import spaces # type: ignore
39
+
40
+ GPU = spaces.GPU
41
+ except ImportError: # local, non-Spaces environment
42
+ def GPU(*args, **kwargs):
43
+ def decorator(fn):
44
+ return fn
45
+
46
+ return decorator
47
+
48
+
49
+ MODEL_ID = "deepreinforce-ai/Ornith-1.0-9B"
50
+
51
+ # Recommended sampling for Ornith (see model card): temp=0.6, top_p=0.95,
52
+ # top_k=20. temp=1.0 reproduces the reported benchmark setup.
53
+ DEFAULT_TEMPERATURE = 0.6
54
+ DEFAULT_MAX_NEW_TOKENS = 2048
55
+
56
+ # Ornith is a reasoning model: the assistant turn opens with a chain-of-
57
+ # thought block before the final answer. We split the two so the UI can
58
+ # render the reasoning separately from the answer. The delimiters are read
59
+ # from the tokenizer config at runtime (Qwen3-style reasoning markers, as
60
+ # Ornith ships a qwen3 reasoning parser) rather than hardcoded, so the
61
+ # raw special-token bytes never need to live in source.
62
+ THINK_OPEN = None # resolved lazily from tokenizer config in _split_think
63
+ THINK_CLOSE = None # resolved lazily from tokenizer config in _split_think
64
+
65
+
66
+ # --------------------------------------------------------------------------- #
67
+ # Model loading (lazy + cached)
68
+ # --------------------------------------------------------------------------- #
69
+ _processor = None
70
+ _model = None
71
+
72
+
73
+ def _load():
74
+ """Load (and cache) the processor + model, moving weights onto the GPU.
75
+
76
+ Wrapped lazily so the heavy download/initialization only happens on the
77
+ first request — important on ZeroGPU, where the container may be scaled
78
+ to zero between visitors.
79
+ """
80
+ global _processor, _model
81
+
82
+ if _processor is None:
83
+ _processor = AutoProcessor.from_pretrained(MODEL_ID)
84
+
85
+ if _model is None:
86
+ _model = AutoModelForMultimodalLM.from_pretrained(
87
+ MODEL_ID,
88
+ torch_dtype=torch.bfloat16,
89
+ low_cpu_mem_usage=True,
90
+ )
91
+
92
+ try:
93
+ _model.to("cuda")
94
+ except Exception:
95
+ # If the device was invalidated (e.g. ZeroGPU freed the accelerator
96
+ # between calls), drop the cached weights so the next call reloads
97
+ # onto a fresh device, then surface the error to the caller.
98
+ _model = None
99
+ raise
100
+
101
+ _model.eval()
102
+ return _processor, _model
103
+
104
+
105
+ def _tokenizer():
106
+ proc = _load()[0]
107
+ return proc.tokenizer if hasattr(proc, "tokenizer") else proc
108
+
109
+
110
+ def _file_path(image: Optional[FileData]) -> Optional[str]:
111
+ """Extract a local filesystem path from a gradio FileData object."""
112
+ if image is None:
113
+ return None
114
+ if isinstance(image, dict):
115
+ return image.get("path")
116
+ return getattr(image, "path", None)
117
+
118
+
119
+ def _resolve_markers():
120
+ """Resolve the reasoning open/close markers from the tokenizer config.
121
+ Falls back to Qwen3-style markers if the config doesn't expose them.
122
+ """
123
+ open_tok = None
124
+ close_tok = None
125
+ try:
126
+ tok = _tokenizer()
127
+ added = getattr(tok, "added_tokens_decoder", {}) or {}
128
+ for entry in added.values():
129
+ content = getattr(entry, "content", None)
130
+ special = getattr(entry, "special", False)
131
+ if not (special and content):
132
+ continue
133
+ # Heuristic: the reasoning block opens with a token whose
134
+ # *decoded* string starts with '<' and matches 'think'.
135
+ low = content.lower()
136
+ if "think" in low and not content.startswith("</"):
137
+ open_tok = content
138
+ elif "think" in low and content.startswith("</"):
139
+ close_tok = content
140
+ except Exception:
141
+ pass
142
+ if not open_tok or not close_tok:
143
+ # Qwen3 defaults (resolved from char codes so they survive
144
+ # this toolchain untouched).
145
+ open_tok = open_tok or "".join(chr(c) for c in (60, 116, 104, 105, 110, 107, 62))
146
+ close_tok = close_tok or "".join(chr(c) for c in (60, 47, 116, 104, 105, 110, 107, 62))
147
+ return open_tok, close_tok
148
+
149
+
150
+ def _split_think(text):
151
+ """Split an Ornith response into (reasoning, answer).
152
+ Reasoning is everything inside the model's chain-of-thought block;
153
+ the answer is what follows. While streaming, the close marker may
154
+ not have arrived yet, in which case everything so far is treated as
155
+ in-progress reasoning and the answer is empty.
156
+ """
157
+ global THINK_OPEN, THINK_CLOSE
158
+ if THINK_OPEN is None or THINK_CLOSE is None:
159
+ THINK_OPEN, THINK_CLOSE = _resolve_markers()
160
+ if THINK_CLOSE and THINK_CLOSE in text:
161
+ head, _, tail = text.partition(THINK_CLOSE)
162
+ reasoning = head.replace(THINK_OPEN or "", "").strip()
163
+ return reasoning, tail.strip()
164
+ # Still inside (or before) the reasoning block.
165
+ return text.replace(THINK_OPEN or "", "").strip(), ""
166
+
167
+
168
+ def _build_messages(message: str, image_path: Optional[str], history):
169
+ """Assemble the chat messages for `apply_chat_template`.
170
+
171
+ Prior turns are carried as text-only turns. The current user turn may
172
+ include an image; per the model card the multimodal content is a list of
173
+ typed parts (image + text).
174
+ """
175
+ messages = []
176
+ for turn in history or []:
177
+ role = turn.get("role")
178
+ content = turn.get("content")
179
+ if role in ("system", "user", "assistant") and content:
180
+ messages.append({"role": role, "content": content})
181
+
182
+ parts = []
183
+ if image_path:
184
+ try:
185
+ img = Image.open(image_path).convert("RGB")
186
+ parts.append({"type": "image", "image": img})
187
+ except Exception:
188
+ # Fall back to letting the processor read the path itself.
189
+ parts.append({"type": "image", "image": image_path})
190
+ parts.append({"type": "text", "text": message or ""})
191
+
192
+ messages.append({"role": "user", "content": parts})
193
+ return messages
194
+
195
+
196
+ # --------------------------------------------------------------------------- #
197
+ # App + streaming endpoint
198
+ # --------------------------------------------------------------------------- #
199
+ app = Server()
200
+
201
+
202
+ def _run_generation(message, image_path, history, max_new_tokens, temperature):
203
+ """Heavy lifting lives here so it can be wrapped with `@spaces.GPU`.
204
+
205
+ This is a generator: each `yield` emits a JSON string describing the
206
+ current state of the response. Gradio streams these to the client via SSE.
207
+ """
208
+ try:
209
+ processor, model = _load()
210
+ tokenizer = processor.tokenizer if hasattr(processor, "tokenizer") else processor
211
+
212
+ messages = _build_messages(message, image_path, history)
213
+
214
+ inputs = processor.apply_chat_template(
215
+ messages,
216
+ add_generation_prompt=True,
217
+ tokenize=True,
218
+ return_dict=True,
219
+ return_tensors="pt",
220
+ )
221
+ # Move every tensor input onto the GPU; leave non-tensors alone.
222
+ inputs = {
223
+ k: (v.to("cuda") if hasattr(v, "to") else v)
224
+ for k, v in inputs.items()
225
+ }
226
+
227
+ streamer = TextIteratorStreamer(
228
+ tokenizer,
229
+ skip_prompt=True,
230
+ skip_special_tokens=True,
231
+ timeout=60.0,
232
+ )
233
+
234
+ pad_token_id = getattr(tokenizer, "eos_token_id", None)
235
+ gen_kwargs = dict(
236
+ **inputs,
237
+ streamer=streamer,
238
+ max_new_tokens=int(max_new_tokens),
239
+ do_sample=True,
240
+ temperature=float(temperature),
241
+ top_p=0.95,
242
+ top_k=20,
243
+ pad_token_id=pad_token_id,
244
+ )
245
+
246
+ thread = Thread(target=model.generate, kwargs=gen_kwargs)
247
+ thread.start()
248
+
249
+ accumulated = ""
250
+ for text in streamer:
251
+ accumulated += text
252
+ reasoning, answer = _split_think(accumulated)
253
+ yield json.dumps(
254
+ {
255
+ "reasoning": reasoning,
256
+ "answer": answer,
257
+ "status": "generating",
258
+ "error": None,
259
+ }
260
+ )
261
+
262
+ thread.join()
263
+ reasoning, answer = _split_think(accumulated)
264
+ yield json.dumps(
265
+ {
266
+ "reasoning": reasoning,
267
+ "answer": answer,
268
+ "status": "complete",
269
+ "error": None,
270
+ }
271
+ )
272
+ except Exception as exc: # noqa: BLE001 - surface any failure to the UI
273
+ yield json.dumps(
274
+ {
275
+ "reasoning": "",
276
+ "answer": "",
277
+ "status": "error",
278
+ "error": str(exc),
279
+ }
280
+ )
281
+
282
+
283
+ @app.api(name="generate", concurrency_limit=1, stream_every=0.5)
284
+ def generate(
285
+ message: str,
286
+ image: Optional[FileData] = None,
287
+ history: list = None,
288
+ max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS,
289
+ temperature: float = DEFAULT_TEMPERATURE,
290
+ ):
291
+ """Stream an Ornith-1.0-9B completion for a chat turn.
292
+
293
+ Args:
294
+ message: the latest user message.
295
+ image: optional image (gradio FileData) for multimodal turns.
296
+ history: prior turns as [{"role": "user"|"assistant", "content": str}].
297
+ max_new_tokens: generation length cap.
298
+ temperature: sampling temperature (0.6 recommended).
299
+
300
+ Yields:
301
+ JSON strings with {reasoning, answer, status, error}.
302
+ """
303
+ if history is None:
304
+ history = []
305
+ image_path = _file_path(image)
306
+ for chunk in _run_generation(
307
+ message, image_path, history, max_new_tokens, temperature
308
+ ):
309
+ yield chunk
310
+
311
+
312
+ @app.get("/", response_class=HTMLResponse)
313
+ async def homepage():
314
+ """Serve the custom chat frontend; this overrides Gradio's default UI."""
315
+ html_path = os.path.join(
316
+ os.path.dirname(os.path.abspath(__file__)), "index.html"
317
+ )
318
+ with open(html_path, "r", encoding="utf-8") as f:
319
+ return f.read()
320
+
321
+
322
+ if __name__ == "__main__":
323
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Ornith-1.0-9B · Chat</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --panel: #161b22;
11
+ --panel-2: #1c232c;
12
+ --border: #30363d;
13
+ --text: #e6edf3;
14
+ --muted: #9da7b3;
15
+ --accent: #ff9d00;
16
+ --accent-2: #6f42c1;
17
+ --user: #2d333b;
18
+ --reason: #14202b;
19
+ --ok: #3fb950;
20
+ --err: #f85149;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ html, body { height: 100%; margin: 0; }
24
+ body {
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
28
+ display: flex;
29
+ flex-direction: column;
30
+ }
31
+ header {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 12px;
35
+ padding: 12px 18px;
36
+ background: linear-gradient(90deg, var(--panel), var(--panel-2));
37
+ border-bottom: 1px solid var(--border);
38
+ }
39
+ .logo { font-size: 26px; line-height: 1; }
40
+ header h1 { font-size: 17px; margin: 0; font-weight: 600; }
41
+ header .sub { color: var(--muted); font-size: 12px; }
42
+ header .spacer { flex: 1; }
43
+ .badge {
44
+ font-size: 11px; color: var(--muted); border: 1px solid var(--border);
45
+ padding: 3px 8px; border-radius: 999px; cursor: pointer; user-select: none;
46
+ }
47
+ .badge:hover { color: var(--text); border-color: var(--muted); }
48
+
49
+ #chat {
50
+ flex: 1;
51
+ overflow-y: auto;
52
+ padding: 20px 18px 8px;
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 14px;
56
+ }
57
+ .msg { max-width: 820px; width: 100%; margin: 0 auto; }
58
+ .bubble {
59
+ border-radius: 12px; padding: 12px 14px; border: 1px solid var(--border);
60
+ white-space: normal; word-wrap: break-word; overflow-wrap: anywhere;
61
+ }
62
+ .msg.user .bubble { background: var(--user); }
63
+ .msg.assistant .bubble { background: var(--panel); }
64
+ .role { font-size: 12px; color: var(--muted); margin: 0 4px 5px; }
65
+ .msg img.attached {
66
+ max-width: 220px; max-height: 220px; border-radius: 8px;
67
+ margin-top: 8px; border: 1px solid var(--border); display: block;
68
+ }
69
+
70
+ .reasoning {
71
+ margin-bottom: 8px; border-left: 3px solid var(--accent-2);
72
+ background: var(--reason); border-radius: 6px; overflow: hidden;
73
+ }
74
+ .reasoning summary {
75
+ cursor: pointer; padding: 6px 10px; font-size: 12px; color: var(--muted);
76
+ list-style: none;
77
+ }
78
+ .reasoning summary::-webkit-details-marker { display: none; }
79
+ .reasoning summary::before { content: "▸ "; }
80
+ .reasoning[open] summary::before { content: "▾ "; }
81
+ .reasoning pre {
82
+ margin: 0; padding: 8px 12px 10px; white-space: pre-wrap;
83
+ word-wrap: break-word; font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
84
+ color: #b9c2cf; max-height: 340px; overflow-y: auto;
85
+ }
86
+ .answer { overflow-x: auto; }
87
+ .answer pre {
88
+ background: #010409; border: 1px solid var(--border); border-radius: 8px;
89
+ padding: 10px 12px; overflow-x: auto; margin: 8px 0;
90
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
91
+ }
92
+ .answer code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
93
+ .answer :not(pre) > code { background: #010409; padding: 1px 5px; border-radius: 4px; }
94
+ .answer p { margin: 6px 0; }
95
+ .answer ul, .answer ol { padding-left: 22px; margin: 6px 0; }
96
+ .answer h1,.answer h2,.answer h3 { margin: 12px 0 6px; }
97
+ .answer a { color: var(--accent); }
98
+ .err { color: var(--err); }
99
+
100
+ #status {
101
+ max-width: 820px; width: 100%; margin: 0 auto;
102
+ color: var(--muted); font-size: 12px; min-height: 16px; padding: 0 4px;
103
+ }
104
+
105
+ .composer {
106
+ border-top: 1px solid var(--border);
107
+ background: var(--panel);
108
+ padding: 12px 18px 16px;
109
+ }
110
+ .composer-inner { max-width: 820px; margin: 0 auto; }
111
+ #settings {
112
+ display: none; gap: 18px; flex-wrap: wrap; margin-bottom: 10px;
113
+ color: var(--muted); font-size: 12px;
114
+ }
115
+ #settings.open { display: flex; }
116
+ #settings label { display: flex; flex-direction: column; gap: 4px; }
117
+ #settings input[type=range] { width: 150px; accent-color: var(--accent); }
118
+ .previews { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
119
+ .previews .thumb { position: relative; }
120
+ .previews .thumb img { height: 56px; border-radius: 6px; border: 1px solid var(--border); }
121
+ .previews .thumb .x {
122
+ position: absolute; top: -6px; right: -6px; cursor: pointer;
123
+ background: var(--panel-2); border: 1px solid var(--border); border-radius: 50%;
124
+ width: 18px; height: 18px; text-align: center; line-height: 16px; font-size: 12px;
125
+ }
126
+ .row { display: flex; gap: 8px; align-items: flex-end; }
127
+ #text {
128
+ flex: 1; resize: none; min-height: 44px; max-height: 180px;
129
+ background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
130
+ border-radius: 10px; padding: 11px 12px; font: inherit; outline: none;
131
+ }
132
+ #text:focus { border-color: var(--accent); }
133
+ button {
134
+ background: var(--accent); color: #1a1200; border: none; border-radius: 10px;
135
+ padding: 11px 16px; font: 600 14px inherit; cursor: pointer;
136
+ }
137
+ button:disabled { opacity: .5; cursor: not-allowed; }
138
+ #stop { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); display: none; }
139
+ .icon-btn {
140
+ background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
141
+ padding: 11px 12px; font-size: 16px;
142
+ }
143
+ .hint { color: var(--muted); font-size: 11px; margin-top: 8px; text-align: center; }
144
+ </style>
145
+ </head>
146
+ <body>
147
+
148
+ <header>
149
+ <div class="logo">🤗</div>
150
+ <div>
151
+ <h1>Ornith-1.0-9B</h1>
152
+ <div class="sub">deepreinforce-ai · multimodal reasoning · gradio.Server</div>
153
+ </div>
154
+ <div class="spacer"></div>
155
+ <div class="badge" id="settings-toggle">⚙ Settings</div>
156
+ </header>
157
+
158
+ <div id="chat"></div>
159
+ <div id="status"></div>
160
+
161
+ <div class="composer">
162
+ <div class="composer-inner">
163
+ <div id="settings">
164
+ <label>Temperature <input type="range" id="temp" min="0.1" max="1.5" step="0.1" value="0.6" /><span id="temp-v">0.6</span></label>
165
+ <label>Max tokens <input type="range" id="maxtok" min="256" max="8192" step="256" value="2048" /><span id="maxtok-v">2048</span></label>
166
+ </div>
167
+ <div class="previews" id="previews"></div>
168
+ <div class="row">
169
+ <button class="icon-btn" id="attach" title="Attach image">📎</button>
170
+ <textarea id="text" placeholder="Message Ornith… (Shift+Enter for newline, Enter to send)"></textarea>
171
+ <button id="send">Send</button>
172
+ <button id="stop">Stop</button>
173
+ </div>
174
+ <div class="hint">Image-text-to-text model. Attach an image and ask about it, or just chat. Reasoning streams in the purple panel.</div>
175
+ </div>
176
+ </div>
177
+
178
+ <input type="file" id="file" accept="image/*" hidden />
179
+
180
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
181
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
182
+ <script type="module">
183
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
184
+
185
+ const chatEl = document.getElementById("chat");
186
+ const statusEl = document.getElementById("status");
187
+ const textEl = document.getElementById("text");
188
+ const sendBtn = document.getElementById("send");
189
+ const stopBtn = document.getElementById("stop");
190
+ const attachBtn= document.getElementById("attach");
191
+ const fileEl = document.getElementById("file");
192
+ const prevEl = document.getElementById("previews");
193
+ const setToggle= document.getElementById("settings-toggle");
194
+ const settings = document.getElementById("settings");
195
+ const tempEl = document.getElementById("temp");
196
+ const tempV = document.getElementById("temp-v");
197
+ const maxEl = document.getElementById("maxtok");
198
+ const maxV = document.getElementById("maxtok-v");
199
+
200
+ let conversation = []; // [{role, content}]
201
+ let pendingImage = null; // File
202
+ let client = null;
203
+ let job = null;
204
+
205
+ const connect = async () => {
206
+ if (!client) client = await Client.connect(window.location.origin, { events: ["status", "data"] });
207
+ return client;
208
+ };
209
+
210
+ setToggle.addEventListener("click", () => settings.classList.toggle("open"));
211
+ tempEl.addEventListener("input", () => tempV.textContent = tempEl.value);
212
+ maxEl.addEventListener("input", () => maxV.textContent = maxEl.value);
213
+
214
+ attachBtn.addEventListener("click", () => fileEl.click());
215
+ fileEl.addEventListener("change", () => {
216
+ if (fileEl.files && fileEl.files[0]) {
217
+ pendingImage = fileEl.files[0];
218
+ renderPreviews();
219
+ }
220
+ fileEl.value = "";
221
+ });
222
+
223
+ function renderPreviews() {
224
+ prevEl.innerHTML = "";
225
+ if (!pendingImage) return;
226
+ const url = URL.createObjectURL(pendingImage);
227
+ const wrap = document.createElement("div");
228
+ wrap.className = "thumb";
229
+ wrap.innerHTML = `<img src="${url}" alt="attachment"/><div class="x" title="remove">×</div>`;
230
+ wrap.querySelector(".x").addEventListener("click", () => { pendingImage = null; renderPreviews(); });
231
+ prevEl.appendChild(wrap);
232
+ }
233
+
234
+ textEl.addEventListener("input", () => {
235
+ textEl.style.height = "auto";
236
+ textEl.style.height = Math.min(textEl.scrollHeight, 180) + "px";
237
+ });
238
+ textEl.addEventListener("keydown", (e) => {
239
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
240
+ });
241
+ sendBtn.addEventListener("click", send);
242
+ stopBtn.addEventListener("click", () => { if (job) job.cancel(); });
243
+
244
+ function scrollIfNear() {
245
+ const near = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 120;
246
+ if (near) chatEl.scrollTop = chatEl.scrollHeight;
247
+ }
248
+
249
+ function addMsg(role) {
250
+ const m = document.createElement("div");
251
+ m.className = "msg " + role;
252
+ const r = document.createElement("div");
253
+ r.className = "role";
254
+ r.textContent = role === "user" ? "You" : "Ornith-1.0-9B";
255
+ const b = document.createElement("div");
256
+ b.className = "bubble";
257
+ m.appendChild(r); m.appendChild(b); chatEl.appendChild(m);
258
+ chatEl.scrollTop = chatEl.scrollHeight;
259
+ return b;
260
+ }
261
+
262
+ async function send() {
263
+ const message = textEl.value.trim();
264
+ if (!message && !pendingImage) return;
265
+ if (job) return;
266
+
267
+ const userBubble = addMsg("user");
268
+ let html = escapeHtml(message);
269
+ if (pendingImage) {
270
+ const url = URL.createObjectURL(pendingImage);
271
+ html += `<img class="attached" src="${url}" alt="attachment"/>`;
272
+ }
273
+ userBubble.innerHTML = html;
274
+
275
+ const sentImage = pendingImage;
276
+ pendingImage = null; renderPreviews();
277
+ textEl.value = ""; textEl.style.height = "auto";
278
+
279
+ const history = conversation.slice();
280
+ conversation.push({ role: "user", content: message });
281
+
282
+ const assistantBubble = addMsg("assistant");
283
+ renderAssistant(assistantBubble, "", "");
284
+
285
+ setBusy(true);
286
+ statusEl.textContent = "Connecting…";
287
+
288
+ try {
289
+ const c = await connect();
290
+ const payload = {
291
+ message,
292
+ image: sentImage ? handle_file(sentImage) : null,
293
+ history,
294
+ max_new_tokens: parseInt(maxEl.value, 10),
295
+ temperature: parseFloat(tempEl.value),
296
+ };
297
+ job = c.submit("/generate", payload);
298
+
299
+ for await (const msg of job) {
300
+ if (msg.type === "status") {
301
+ const s = msg.status || msg.data?.status;
302
+ if (s === "pending") statusEl.textContent = `Queued…${msg.position != null ? " (#" + msg.position + ")" : ""}`;
303
+ else if (s === "generating") statusEl.textContent = "Generating…";
304
+ else if (s === "complete" || s === "error") statusEl.textContent = "";
305
+ continue;
306
+ }
307
+ if (msg.type !== "data") continue;
308
+ const raw = Array.isArray(msg.data) ? msg.data[0] : msg.data;
309
+ let d = raw;
310
+ if (typeof raw === "string") { try { d = JSON.parse(raw); } catch { d = { answer: raw }; } }
311
+ if (d && d.error) {
312
+ renderAssistant(assistantBubble, d.reasoning || "", d.answer || "", d.error);
313
+ statusEl.innerHTML = `<span class="err">Error: ${escapeHtml(d.error)}</span>`;
314
+ break;
315
+ }
316
+ renderAssistant(assistantBubble, d?.reasoning || "", d?.answer || "");
317
+ if (d && d.status === "complete") {
318
+ if (d.answer) conversation.push({ role: "assistant", content: d.answer });
319
+ }
320
+ scrollIfNear();
321
+ }
322
+ } catch (e) {
323
+ renderAssistant(assistantBubble, "", "", String(e));
324
+ statusEl.innerHTML = `<span class="err">${escapeHtml(String(e))}</span>`;
325
+ } finally {
326
+ job = null;
327
+ setBusy(false);
328
+ statusEl.textContent = "";
329
+ }
330
+ }
331
+
332
+ function renderAssistant(bubble, reasoning, answer, error) {
333
+ let h = "";
334
+ if (reasoning && reasoning.trim()) {
335
+ const open = error === undefined ? " open" : ""; // collapse once answer lands
336
+ h += `<details class="reasoning"${answer ? "" : " open"}><summary>Reasoning</summary><pre>${escapeHtml(reasoning)}</pre></details>`;
337
+ }
338
+ if (error) {
339
+ h += `<div class="err">⚠ ${escapeHtml(error)}</div>`;
340
+ } else if (answer && answer.trim()) {
341
+ h += `<div class="answer">${DOMPurify.sanitize(marked.parse(answer))}</div>`;
342
+ } else if (!reasoning) {
343
+ h += `<span style="color:var(--muted)">…</span>`;
344
+ }
345
+ bubble.innerHTML = h;
346
+ }
347
+
348
+ function setBusy(busy) {
349
+ sendBtn.disabled = busy;
350
+ sendBtn.style.display = busy ? "none" : "";
351
+ stopBtn.style.display = busy ? "" : "none";
352
+ }
353
+
354
+ function escapeHtml(s) {
355
+ return String(s ?? "")
356
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
357
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
358
+ }
359
+
360
+ // Focus the input on load.
361
+ textEl.focus();
362
+ </script>
363
+ </body>
364
+ </html>
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Hugging Face ZeroGPU Spaces ship a CUDA-enabled torch in the base image, so
2
+ # do NOT add torch here (a pip install would pull a CPU wheel and break GPU).
3
+ gradio>=6.19.0
4
+ spaces
5
+ transformers>=5.8.1
6
+ accelerate
7
+ pillow