Spaces:
Running on Zero
Running on Zero
Add gradio.Server chat demo for Ornith-1.0-9B
Browse filesgradio.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>
- README.md +15 -0
- app.py +323 -0
- index.html +364 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
| 357 |
+
.replace(/"/g, """).replace(/'/g, "'");
|
| 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
|