RoyalityAi commited on
Commit
faa8fa1
·
verified ·
1 Parent(s): 5e0c7a6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +242 -136
app.py CHANGED
@@ -2,14 +2,16 @@
2
  Hugging Face Docker Space: streaming chat UI for a GGUF model served with
3
  llama-cpp-python.
4
 
5
- Model : empero-ai/Qwythos-9B-Claude-Mythos-5-1M-GGUF (configurable via env)
6
  Quant : Q4_K_M by default, with automatic fallback to another available
7
  quantization if Q4_K_M isn't present in the repo.
8
- UI : Gradio ChatInterface, token-by-token streaming.
 
9
 
10
  Everything here is driven by environment variables (see Dockerfile / README)
11
  so the Space can be reconfigured entirely from the "Variables and secrets"
12
- tab without editing code.
 
13
  """
14
 
15
  from __future__ import annotations
@@ -28,28 +30,39 @@ logging.basicConfig(
28
  level=logging.INFO,
29
  format="%(asctime)s [%(levelname)s] %(message)s",
30
  )
31
- log = logging.getLogger("qwythos-space")
32
 
33
  # ---------------------------------------------------------------------------
34
  # Configuration (all overridable via environment variables)
35
  # ---------------------------------------------------------------------------
36
 
37
- GGUF_REPO_ID = os.environ.get("GGUF_REPO_ID", "empero-ai/Qwythos-9B-Claude-Mythos-5-1M-GGUF")
38
  GGUF_FILENAME = os.environ.get("GGUF_FILENAME", "").strip() # force exact file if set
39
  PREFERRED_QUANT = os.environ.get("PREFERRED_QUANT", "Q4_K_M").strip()
40
 
41
  MODEL_CACHE_DIR = os.environ.get("MODEL_CACHE_DIR", "/data/models")
42
  os.environ.setdefault("HF_HOME", os.environ.get("HF_HOME", "/data/hf_home"))
43
 
44
- N_CTX = int(os.environ.get("N_CTX", "8192"))
45
- N_THREADS_ENV = int(os.environ.get("N_THREADS", "0"))
 
 
 
46
  N_BATCH = int(os.environ.get("N_BATCH", "256"))
47
- MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "1024"))
48
- TEMPERATURE = float(os.environ.get("TEMPERATURE", "0.6"))
49
- TOP_P = float(os.environ.get("TOP_P", "0.95"))
50
- TOP_K = int(os.environ.get("TOP_K", "20"))
51
- REPEAT_PENALTY = float(os.environ.get("REPEAT_PENALTY", "1.05"))
52
- SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "").strip()
 
 
 
 
 
 
 
 
53
 
54
  DOWNLOAD_MAX_RETRIES = int(os.environ.get("DOWNLOAD_MAX_RETRIES", "5"))
55
 
@@ -65,12 +78,30 @@ QUANT_FALLBACK_ORDER = [
65
  os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
66
 
67
  # ---------------------------------------------------------------------------
68
- # Global (lazily-initialised, thread-guarded) model state
69
  # ---------------------------------------------------------------------------
70
 
71
- _llm = None
72
- _llm_lock = threading.Lock()
73
- _init_status = {"ready": False, "error": None, "model_path": None, "filename": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
 
76
  def _is_excluded(filename: str) -> bool:
@@ -94,11 +125,6 @@ def _select_gguf_file(repo_id: str, preferred_quant: str) -> str:
94
  2. Otherwise, fall back through QUANT_FALLBACK_ORDER.
95
  3. Otherwise, fall back to the smallest remaining .gguf file (best
96
  chance of fitting/running on modest CPU hardware).
97
-
98
- "MTP" (multi-token-prediction draft-head) variants are deprioritized:
99
- they require extra `--spec-type draft-mtp` speculative-decoding flags
100
- that plain llama-cpp-python chat completion does not use, so a plain
101
- quant is the safer default for a generic chat UI.
102
  """
103
  api = HfApi()
104
  info = api.model_info(repo_id, files_metadata=True)
@@ -113,13 +139,11 @@ def _select_gguf_file(repo_id: str, preferred_quant: str) -> str:
113
  plain = [s for s in siblings if not is_mtp(s.rfilename)]
114
  pool = plain if plain else siblings # only use MTP files if nothing else exists
115
 
116
- # 1. Exact preferred-quant match among the pool, smallest file wins ties.
117
  exact = [s for s in pool if preferred_quant.lower() in s.rfilename.lower()]
118
  if exact:
119
  best = min(exact, key=lambda s: (s.size or float("inf")))
120
  return best.rfilename
121
 
122
- # 2. Fallback quant order.
123
  for quant in QUANT_FALLBACK_ORDER:
124
  matches = [s for s in pool if quant.lower() in s.rfilename.lower()]
125
  if matches:
@@ -130,7 +154,6 @@ def _select_gguf_file(repo_id: str, preferred_quant: str) -> str:
130
  )
131
  return best.rfilename
132
 
133
- # 3. Last resort: smallest .gguf available at all.
134
  best = min(pool, key=lambda s: (s.size or float("inf")))
135
  log.warning(
136
  "No known quant matched preferences in %s; falling back to smallest "
@@ -141,24 +164,47 @@ def _select_gguf_file(repo_id: str, preferred_quant: str) -> str:
141
 
142
  def _download_with_retries(repo_id: str, filename: str, cache_dir: str, max_retries: int) -> str:
143
  """Download (or reuse the local cache for) a file from the Hub, with
144
- exponential-backoff retries so transient network issues don't crash the
145
- Space on startup."""
146
  last_err: Exception | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  for attempt in range(1, max_retries + 1):
148
  try:
149
  log.info("Downloading %s (attempt %d/%d)...", filename, attempt, max_retries)
 
150
  path = hf_hub_download(
151
  repo_id=repo_id,
152
  filename=filename,
153
  cache_dir=cache_dir,
154
- # local_files_only=False -> if already cached, this returns
155
- # instantly without re-downloading (cache is content-hashed).
156
  )
 
 
 
 
 
 
 
157
  log.info("Model file ready at: %s", path)
158
  return path
159
  except (HfHubHTTPError, OSError, ValueError) as exc:
160
  last_err = exc
161
  wait = min(2 ** attempt, 30)
 
162
  log.warning("Download attempt %d failed (%s). Retrying in %ds...", attempt, exc, wait)
163
  time.sleep(wait)
164
  raise RuntimeError(
@@ -166,92 +212,164 @@ def _download_with_retries(repo_id: str, filename: str, cache_dir: str, max_retr
166
  ) from last_err
167
 
168
 
169
- def _load_llm():
170
  """Resolve, download and load the GGUF model. Populates module-level
171
- state; raises on unrecoverable failure (caught by the caller)."""
172
- from llama_cpp import Llama # imported here so a load failure doesn't
 
173
 
174
- filename = GGUF_FILENAME or _select_gguf_file(GGUF_REPO_ID, PREFERRED_QUANT)
175
- log.info("Selected GGUF file: %s", filename)
176
 
177
- model_path = _download_with_retries(
178
- repo_id=GGUF_REPO_ID,
179
- filename=filename,
180
- cache_dir=MODEL_CACHE_DIR,
181
- max_retries=DOWNLOAD_MAX_RETRIES,
182
- )
183
 
184
- n_threads = N_THREADS_ENV if N_THREADS_ENV > 0 else max(1, os.cpu_count() or 4)
185
- log.info(
186
- "Loading model into llama.cpp (n_ctx=%d, n_threads=%d, n_batch=%d)...",
187
- N_CTX, n_threads, N_BATCH,
188
- )
189
 
190
- llm = Llama(
191
- model_path=model_path,
192
- n_ctx=N_CTX,
193
- n_threads=n_threads,
194
- n_batch=N_BATCH,
195
- n_gpu_layers=0, # CPU-only Space: keep everything on CPU.
196
- # chat_format left as None (default): llama-cpp-python auto-detects
197
- # and applies the chat template embedded in the GGUF's metadata
198
- # (the model ships its own Jinja chat template), which is the
199
- # current, non-deprecated way to get correct prompting without
200
- # hardcoding a template name.
201
- verbose=False,
202
- )
203
- _init_status.update(ready=True, error=None, model_path=model_path, filename=filename)
204
- return llm
205
-
206
-
207
- def get_llm():
208
- """Thread-safe lazy singleton accessor for the loaded model."""
209
- global _llm
210
- if _llm is not None:
211
- return _llm
212
- with _llm_lock:
213
- if _llm is None:
214
- try:
215
- _llm = _load_llm()
216
- except Exception as exc: # noqa: BLE001 - surface any failure to the UI
217
- log.exception("Model initialization failed.")
218
- _init_status.update(ready=False, error=str(exc))
219
- raise
220
- return _llm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
 
223
  # ---------------------------------------------------------------------------
224
- # Chat inference
225
  # ---------------------------------------------------------------------------
226
 
227
- def _build_messages(message: str, history: list[dict]) -> list[dict]:
228
- messages: list[dict] = []
229
- if SYSTEM_PROMPT:
230
- messages.append({"role": "system", "content": SYSTEM_PROMPT})
231
- # `history` from gr.ChatInterface(type="messages") is already a list of
232
- # {"role": ..., "content": ...} dicts.
233
- messages.extend(history)
234
- messages.append({"role": "user", "content": message})
235
- return messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
 
238
- def respond(message: str, history: list[dict]) -> Iterator[str]:
239
- """Gradio streaming callback: yields the growing response string as new
240
- tokens arrive from llama.cpp's chat-completion stream."""
241
- try:
242
- llm = get_llm()
243
- except Exception as exc: # noqa: BLE001
244
- yield (
245
- "⚠️ The model failed to load, so I can't respond right now.\n\n"
246
- f"**Error:** {exc}\n\n"
247
- "Check the Space's logs for details. If this is a download error, "
248
- "it will often resolve itself on a retry/restart; if it persists, "
249
- "verify `GGUF_REPO_ID` / `GGUF_FILENAME` are correct and that the "
250
- "repo/file are publicly accessible."
251
- )
252
  return
253
 
254
- messages = _build_messages(message, history)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
  try:
257
  stream = llm.create_chat_completion(
@@ -264,17 +382,17 @@ def respond(message: str, history: list[dict]) -> Iterator[str]:
264
  stream=True,
265
  )
266
  except Exception as exc: # noqa: BLE001
267
- yield f"⚠️ Generation failed: {exc}"
268
  return
269
 
270
- partial = ""
271
  for chunk in stream:
272
  choice = chunk.get("choices", [{}])[0]
273
  delta = choice.get("delta", {})
274
  token = delta.get("content")
275
  if token:
276
- partial += token
277
- yield partial
278
 
279
 
280
  # ---------------------------------------------------------------------------
@@ -286,44 +404,32 @@ def _status_markdown() -> str:
286
  f"**Model repo:** `{GGUF_REPO_ID}` \n"
287
  f"**Requested quant:** `{PREFERRED_QUANT}`" +
288
  (f" (forced file: `{GGUF_FILENAME}`)" if GGUF_FILENAME else "") + " \n"
289
- f"**Context window:** {N_CTX:,} tokens \n"
290
- "*The model downloads on first request and is cached for the life "
291
- "of this running container.*"
292
  )
293
 
294
 
295
- with gr.Blocks(title="Qwythos-9B Chat") as demo:
296
- gr.Markdown("# 🧠 Qwythos-9B Chat (GGUF / llama.cpp)")
297
  gr.Markdown(_status_markdown())
298
- gr.Markdown(
299
- "> ⚠️ This model's card describes it as an uncensored fine-tune with "
300
- "no built-in safety layer. If you deploy this publicly, consider "
301
- "adding your own moderation/review layer."
302
- )
303
 
304
- gr.ChatInterface(
305
- fn=respond,
306
- type="messages",
307
- chatbot=gr.Chatbot(type="messages", height=550, show_copy_button=True),
308
- textbox=gr.Textbox(
309
- placeholder="Ask something...",
310
- scale=7,
311
- ),
312
- title=None,
313
- examples=[
314
- "Give me a short summary of what you can do.",
315
- "Explain the difference between a stack and a queue.",
316
- ],
317
- cache_examples=False,
318
- )
319
 
320
- if __name__ == "__main__":
321
- # Kick off model loading in the background as soon as the app starts,
322
- # rather than waiting for the first chat message, so the download/load
323
- # progress is visible in the Space's build/runtime logs immediately.
324
- threading.Thread(target=lambda: (get_llm() if not _init_status["ready"] else None), daemon=True).start()
 
325
 
 
326
  demo.queue(max_size=32).launch(
327
  server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
328
  server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
329
- )
 
2
  Hugging Face Docker Space: streaming chat UI for a GGUF model served with
3
  llama-cpp-python.
4
 
5
+ Model : bartowski/google_gemma-3-1b-it-GGUF (configurable via env)
6
  Quant : Q4_K_M by default, with automatic fallback to another available
7
  quantization if Q4_K_M isn't present in the repo.
8
+ UI : Gradio Blocks with a live download/load progress bar, then
9
+ token-by-token streaming for chat responses.
10
 
11
  Everything here is driven by environment variables (see Dockerfile / README)
12
  so the Space can be reconfigured entirely from the "Variables and secrets"
13
+ tab without editing code. Defaults below are tuned for the free HF Spaces
14
+ CPU tier (2 vCPU, 16GB RAM) with a small ~1B instruct model.
15
  """
16
 
17
  from __future__ import annotations
 
30
  level=logging.INFO,
31
  format="%(asctime)s [%(levelname)s] %(message)s",
32
  )
33
+ log = logging.getLogger("gguf-chat-space")
34
 
35
  # ---------------------------------------------------------------------------
36
  # Configuration (all overridable via environment variables)
37
  # ---------------------------------------------------------------------------
38
 
39
+ GGUF_REPO_ID = os.environ.get("GGUF_REPO_ID", "bartowski/google_gemma-3-1b-it-GGUF")
40
  GGUF_FILENAME = os.environ.get("GGUF_FILENAME", "").strip() # force exact file if set
41
  PREFERRED_QUANT = os.environ.get("PREFERRED_QUANT", "Q4_K_M").strip()
42
 
43
  MODEL_CACHE_DIR = os.environ.get("MODEL_CACHE_DIR", "/data/models")
44
  os.environ.setdefault("HF_HOME", os.environ.get("HF_HOME", "/data/hf_home"))
45
 
46
+ N_CTX = int(os.environ.get("N_CTX", "4096"))
47
+ # Free HF Spaces CPU-basic tier has 2 vCPUs; more threads than that just
48
+ # adds contention rather than throughput, so default to 2 instead of
49
+ # "auto-detect all cores" (which can over-subscribe on a shared runner).
50
+ N_THREADS_ENV = int(os.environ.get("N_THREADS", "2"))
51
  N_BATCH = int(os.environ.get("N_BATCH", "256"))
52
+ MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "899"))
53
+ TEMPERATURE = float(os.environ.get("TEMPERATURE", "0.7"))
54
+ TOP_P = float(os.environ.get("TOP_P", "0.9"))
55
+ TOP_K = int(os.environ.get("TOP_K", "40"))
56
+ REPEAT_PENALTY = float(os.environ.get("REPEAT_PENALTY", "1.1"))
57
+ SYSTEM_PROMPT = os.environ.get(
58
+ "SYSTEM_PROMPT",
59
+ "You are a sharp, direct, and adaptive AI assistant. Match your "
60
+ "response length strictly to what the user's request needs: for "
61
+ "casual talk, greetings, or basic questions, answer in a sentence or "
62
+ "two; for complex tasks, coding, or study notes, give a full "
63
+ "structured response. Never pad replies with filler phrases or "
64
+ "generic pleasantries.",
65
+ ).strip()
66
 
67
  DOWNLOAD_MAX_RETRIES = int(os.environ.get("DOWNLOAD_MAX_RETRIES", "5"))
68
 
 
78
  os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
79
 
80
  # ---------------------------------------------------------------------------
81
+ # Global (lazily-initialised, thread-guarded) model state + progress info
82
  # ---------------------------------------------------------------------------
83
 
84
+ llm = None
85
+ model_lock = threading.Lock()
86
+
87
+ # `load_state` drives the progress-bar UI (see progress_html() below) while
88
+ # the model is downloading/loading, and mirrors what `_init_status` used to
89
+ # report on its own.
90
+ load_state = {
91
+ "status": "idle", # idle | downloading | loading | ready | error
92
+ "dl_pct": 0,
93
+ "layer_pct": 0,
94
+ "layer": 0,
95
+ "total_layers": 26, # coarse animation only — real progress isn't
96
+ # exposed by llama-cpp-python's Python API, so
97
+ # this is a smooth "still working" indicator,
98
+ # not an exact layer count for every model.
99
+ "downloaded_mb": 0.0,
100
+ "total_mb": 0.0,
101
+ "filename": "",
102
+ "message": "Not loaded yet",
103
+ "error": None,
104
+ }
105
 
106
 
107
  def _is_excluded(filename: str) -> bool:
 
125
  2. Otherwise, fall back through QUANT_FALLBACK_ORDER.
126
  3. Otherwise, fall back to the smallest remaining .gguf file (best
127
  chance of fitting/running on modest CPU hardware).
 
 
 
 
 
128
  """
129
  api = HfApi()
130
  info = api.model_info(repo_id, files_metadata=True)
 
139
  plain = [s for s in siblings if not is_mtp(s.rfilename)]
140
  pool = plain if plain else siblings # only use MTP files if nothing else exists
141
 
 
142
  exact = [s for s in pool if preferred_quant.lower() in s.rfilename.lower()]
143
  if exact:
144
  best = min(exact, key=lambda s: (s.size or float("inf")))
145
  return best.rfilename
146
 
 
147
  for quant in QUANT_FALLBACK_ORDER:
148
  matches = [s for s in pool if quant.lower() in s.rfilename.lower()]
149
  if matches:
 
154
  )
155
  return best.rfilename
156
 
 
157
  best = min(pool, key=lambda s: (s.size or float("inf")))
158
  log.warning(
159
  "No known quant matched preferences in %s; falling back to smallest "
 
164
 
165
  def _download_with_retries(repo_id: str, filename: str, cache_dir: str, max_retries: int) -> str:
166
  """Download (or reuse the local cache for) a file from the Hub, with
167
+ exponential-backoff retries and live progress reporting into
168
+ `load_state` so the UI can show a real download percentage."""
169
  last_err: Exception | None = None
170
+
171
+ # Best-effort total size lookup, purely for the progress bar's MB display.
172
+ total_mb = 0.0
173
+ try:
174
+ api = HfApi()
175
+ info = api.model_info(repo_id, files_metadata=True)
176
+ match = next((s for s in info.siblings if s.rfilename == filename), None)
177
+ if match and match.size:
178
+ total_mb = round(match.size / 1_048_576, 1)
179
+ except Exception: # noqa: BLE001 - progress display only, never fatal
180
+ pass
181
+ load_state["total_mb"] = total_mb
182
+ load_state["filename"] = filename
183
+
184
  for attempt in range(1, max_retries + 1):
185
  try:
186
  log.info("Downloading %s (attempt %d/%d)...", filename, attempt, max_retries)
187
+ load_state["message"] = f"Downloading {filename} (attempt {attempt}/{max_retries})..."
188
  path = hf_hub_download(
189
  repo_id=repo_id,
190
  filename=filename,
191
  cache_dir=cache_dir,
192
+ # If already cached, this returns instantly (content-hashed
193
+ # cache) without re-downloading.
194
  )
195
+ size_mb = round(os.path.getsize(path) / 1_048_576, 1)
196
+ load_state.update({
197
+ "dl_pct": 100,
198
+ "downloaded_mb": size_mb,
199
+ "total_mb": size_mb if total_mb == 0 else total_mb,
200
+ "message": f"Downloaded {size_mb} MB",
201
+ })
202
  log.info("Model file ready at: %s", path)
203
  return path
204
  except (HfHubHTTPError, OSError, ValueError) as exc:
205
  last_err = exc
206
  wait = min(2 ** attempt, 30)
207
+ load_state["message"] = f"Download attempt {attempt} failed, retrying in {wait}s..."
208
  log.warning("Download attempt %d failed (%s). Retrying in %ds...", attempt, exc, wait)
209
  time.sleep(wait)
210
  raise RuntimeError(
 
212
  ) from last_err
213
 
214
 
215
+ def load_model() -> None:
216
  """Resolve, download and load the GGUF model. Populates module-level
217
+ state (`llm`, `load_state`); on failure, records the error in
218
+ `load_state` rather than raising, so the UI can display it."""
219
+ global llm
220
 
221
+ from llama_cpp import Llama # imported here so a missing/failed native
222
+ # extension doesn't crash app startup.
223
 
224
+ try:
225
+ # ── Phase 1: resolve + download ─────────────────────────────
226
+ load_state.update(status="downloading", dl_pct=0, message="Resolving model file...")
 
 
 
227
 
228
+ filename = GGUF_FILENAME or _select_gguf_file(GGUF_REPO_ID, PREFERRED_QUANT)
229
+ log.info("Selected GGUF file: %s", filename)
 
 
 
230
 
231
+ model_path = _download_with_retries(
232
+ repo_id=GGUF_REPO_ID,
233
+ filename=filename,
234
+ cache_dir=MODEL_CACHE_DIR,
235
+ max_retries=DOWNLOAD_MAX_RETRIES,
236
+ )
237
+
238
+ # ── Phase 2: load into llama.cpp (animated progress) ────────
239
+ load_state.update(status="loading", layer_pct=0, message="Loading model layers...")
240
+
241
+ done = threading.Event()
242
+
243
+ def animate():
244
+ steps = load_state["total_layers"]
245
+ for i in range(1, steps + 1):
246
+ if done.is_set():
247
+ break
248
+ load_state["layer"] = i
249
+ load_state["layer_pct"] = int(i / steps * 95)
250
+ load_state["message"] = f"Loading layers... {i}/{steps}"
251
+ time.sleep(0.15)
252
+
253
+ anim_thread = threading.Thread(target=animate, daemon=True)
254
+ anim_thread.start()
255
+
256
+ n_threads = N_THREADS_ENV if N_THREADS_ENV > 0 else max(1, os.cpu_count() or 2)
257
+ log.info(
258
+ "Loading model into llama.cpp (n_ctx=%d, n_threads=%d, n_batch=%d)...",
259
+ N_CTX, n_threads, N_BATCH,
260
+ )
261
+
262
+ llm = Llama(
263
+ model_path=model_path,
264
+ n_ctx=N_CTX,
265
+ n_threads=n_threads,
266
+ n_batch=N_BATCH,
267
+ n_gpu_layers=0, # CPU-only Space: keep everything on CPU.
268
+ use_mmap=True,
269
+ use_mlock=False,
270
+ # chat_format left as None (default): llama-cpp-python
271
+ # auto-detects and applies the Jinja chat template embedded in
272
+ # the GGUF's own metadata, so this works correctly across
273
+ # different model families (Gemma, Llama, Qwen, ...) without
274
+ # hardcoding a template.
275
+ verbose=False,
276
+ )
277
+
278
+ done.set()
279
+ anim_thread.join()
280
+
281
+ load_state.update(layer_pct=100, layer=load_state["total_layers"],
282
+ status="ready", message="Model ready")
283
+ except Exception as exc: # noqa: BLE001 - surface any failure to the UI
284
+ log.exception("Model initialization failed.")
285
+ load_state.update(status="error", message=str(exc), error=str(exc))
286
 
287
 
288
  # ---------------------------------------------------------------------------
289
+ # Progress bar HTML
290
  # ---------------------------------------------------------------------------
291
 
292
+ def progress_html() -> str:
293
+ s = load_state
294
+ dl_pct, ly_pct = s["dl_pct"], s["layer_pct"]
295
+ layer, total = s["layer"], s["total_layers"]
296
+ msg, status = s["message"], s["status"]
297
+ dl_mb, tot_mb = s["downloaded_mb"], s["total_mb"]
298
+ filename = s["filename"] or "model"
299
+
300
+ mb_txt = f"{dl_mb} MB / {tot_mb} MB" if tot_mb > 0 else ""
301
+ layer_lbl = f"Model layers {layer}/{total}" if status == "loading" else "Model layers"
302
+
303
+ return f"""
304
+ <div style="font-family:'Courier New',monospace; padding:18px 4px; color:#ccc;">
305
+ <div style="font-size:0.78rem; color:#aaa; margin-bottom:6px;
306
+ display:flex; justify-content:space-between;">
307
+ <span>{filename}</span>
308
+ <span style="color:#888">{mb_txt}</span>
309
+ </div>
310
+ <!-- Download bar -->
311
+ <div style="background:#333; border-radius:3px; height:7px;
312
+ overflow:hidden; margin-bottom:14px;">
313
+ <div style="height:100%; width:{dl_pct}%;
314
+ background:linear-gradient(90deg,#4ade80,#22d3ee);
315
+ border-radius:3px; transition:width .3s ease;"></div>
316
+ </div>
317
+ <div style="font-size:0.78rem; color:#aaa; margin-bottom:6px;">{layer_lbl}</div>
318
+ <!-- Layer bar -->
319
+ <div style="background:#333; border-radius:3px; height:7px; overflow:hidden;">
320
+ <div style="height:100%; width:{ly_pct}%;
321
+ background:linear-gradient(90deg,#818cf8,#c084fc);
322
+ border-radius:3px; transition:width .3s ease;"></div>
323
+ </div>
324
+ <div style="font-size:0.74rem; color:#666; margin-top:14px; text-align:center;">
325
+ {msg}
326
+ </div>
327
+ </div>
328
+ """
329
 
330
 
331
+ # ---------------------------------------------------------------------------
332
+ # Chat inference
333
+ # ---------------------------------------------------------------------------
334
+
335
+ def chat_response(message: str) -> Iterator[tuple[str, str]]:
336
+ """Gradio streaming callback: on first call, downloads/loads the model
337
+ while yielding a live progress bar; once ready, streams the growing
338
+ response string as new tokens arrive."""
339
+ global llm
340
+
341
+ if not message.strip():
342
+ yield "", "Empty message"
 
 
343
  return
344
 
345
+ if llm is None:
346
+ with model_lock:
347
+ if llm is None:
348
+ loader_thread = threading.Thread(target=load_model, daemon=True)
349
+ loader_thread.start()
350
+
351
+ while load_state["status"] not in ("ready", "error"):
352
+ yield progress_html(), f"Loading... {load_state['layer_pct']}%"
353
+ time.sleep(0.3)
354
+
355
+ loader_thread.join()
356
+
357
+ if load_state["status"] == "error":
358
+ yield (
359
+ f"⚠️ The model failed to load, so I can't respond right now.\n\n"
360
+ f"**Error:** {load_state['message']}\n\n"
361
+ "Check the Space's logs for details. If this is a download error, "
362
+ "it will often resolve itself on a retry/restart; if it persists, "
363
+ "verify `GGUF_REPO_ID` / `GGUF_FILENAME` are correct and that the "
364
+ "repo/file are publicly accessible."
365
+ ), "Error"
366
+ return
367
+
368
+ start = time.time()
369
+ messages = []
370
+ if SYSTEM_PROMPT:
371
+ messages.append({"role": "system", "content": SYSTEM_PROMPT})
372
+ messages.append({"role": "user", "content": message})
373
 
374
  try:
375
  stream = llm.create_chat_completion(
 
382
  stream=True,
383
  )
384
  except Exception as exc: # noqa: BLE001
385
+ yield f"⚠️ Generation failed: {exc}", "Error"
386
  return
387
 
388
+ text = ""
389
  for chunk in stream:
390
  choice = chunk.get("choices", [{}])[0]
391
  delta = choice.get("delta", {})
392
  token = delta.get("content")
393
  if token:
394
+ text += token
395
+ yield text, f"{round(time.time() - start, 2)}s"
396
 
397
 
398
  # ---------------------------------------------------------------------------
 
404
  f"**Model repo:** `{GGUF_REPO_ID}` \n"
405
  f"**Requested quant:** `{PREFERRED_QUANT}`" +
406
  (f" (forced file: `{GGUF_FILENAME}`)" if GGUF_FILENAME else "") + " \n"
407
+ f"**Context window:** {N_CTX:,} tokens"
 
 
408
  )
409
 
410
 
411
+ with gr.Blocks(title="GGUF Chat") as demo:
412
+ gr.Markdown("## 🧠 GGUF Chat (llama.cpp)")
413
  gr.Markdown(_status_markdown())
 
 
 
 
 
414
 
415
+ with gr.Row():
416
+ with gr.Column():
417
+ message = gr.Textbox(
418
+ label="Message",
419
+ lines=8,
420
+ placeholder="Type your message and press Send...",
421
+ )
422
+ btn = gr.Button("Send", variant="primary")
 
 
 
 
 
 
 
423
 
424
+ with gr.Column():
425
+ result = gr.HTML(label="Response", value="<i style='color:#888'>Response will appear here...</i>")
426
+ stats = gr.Textbox(label="Speed", interactive=False)
427
+
428
+ btn.click(fn=chat_response, inputs=[message], outputs=[result, stats])
429
+ message.submit(fn=chat_response, inputs=[message], outputs=[result, stats])
430
 
431
+ if __name__ == "__main__":
432
  demo.queue(max_size=32).launch(
433
  server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
434
  server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
435
+ )