GitHub Actions commited on
Commit
688a2dc
·
1 Parent(s): 833276b

chore: runtime updates and fixes

Browse files

- Updated HF Transformers backend configuration
- Refined UI application state
- Updated README and dependencies

README.md CHANGED
@@ -17,6 +17,7 @@ tags:
17
  - minicpm
18
  - modal
19
  - codex
 
20
  license: apache-2.0
21
  ---
22
 
@@ -63,6 +64,8 @@ license: apache-2.0
63
 
64
  > 📣 **Social post:** [tweet on x](https://twitter.com/zX14_7/status/2064853015622775047) [tweet on x](https://twitter.com/zX14_7/status/2064853015622775047)
65
  [Post on blogger](https://ckaller.blogspot.com/2026/06/hearthnet-building-ai-that-works-when.html)
 
 
66
  >
67
  > **June 14 bug-fix release:** 8 critical bugs fixed — seed corpus now actually ingested,
68
  > node lifecycle corrected (`stop()` previously silently no-oped), sticky session memory
 
17
  - minicpm
18
  - modal
19
  - codex
20
+ - push e or a for easteregg
21
  license: apache-2.0
22
  ---
23
 
 
64
 
65
  > 📣 **Social post:** [tweet on x](https://twitter.com/zX14_7/status/2064853015622775047) [tweet on x](https://twitter.com/zX14_7/status/2064853015622775047)
66
  [Post on blogger](https://ckaller.blogspot.com/2026/06/hearthnet-building-ai-that-works-when.html)
67
+
68
+ [LinkedIn Post](https://www.linkedin.com/posts/christof-kaller-6b043733_ai-opensource-huggingface-share-7472317969595863040-cK6Z/?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAcBRiQBdJnC2ODS2UoAdsqfUNZlkb_lFJk)
69
  >
70
  > **June 14 bug-fix release:** 8 critical bugs fixed — seed corpus now actually ingested,
71
  > node lifecycle corrected (`stop()` previously silently no-oped), sticky session memory
app.py CHANGED
@@ -13,7 +13,7 @@ LLM backend. All 8 tabs are live:
13
 
14
  Difference between this Space and a local install
15
  ──────────────────────────────────────────────────
16
- HF Space → single node, no real peer mesh, SmolLM2-135M for LLM
17
  Local node → full peer mesh, any LLM backend (Ollama / llama.cpp / HF),
18
  file sharing, multi-node chat, hardware acceleration
19
 
@@ -46,7 +46,7 @@ except ImportError:
46
  # Bootstrap a real HearthNet node
47
  # ─────────────────────────────────────────────────────────────────────────────
48
 
49
- MODEL_ID = os.getenv("MODEL_ID", "openbmb/MiniCPM3-4B")
50
  MODEL_REVISION = os.getenv("MODEL_REVISION") or None
51
 
52
  SEED_CORPUS = [
@@ -161,7 +161,7 @@ SEED_CORPUS = [
161
  def _build_node():
162
  """Bootstrap the HearthNet node for this Space.
163
 
164
- Uses HfLocalBackend (SmolLM2-135M) so inference works without Ollama.
165
  Falls back to _UnavailableBackend if transformers is not installed.
166
  """
167
  import hashlib
@@ -194,70 +194,21 @@ def _build_node():
194
  community_id="ed25519:hf-space-community",
195
  )
196
 
197
- # LLM — HF Transformers backend (SmolLM2 by default)
198
  try:
199
  backend = HfLocalBackend(model=MODEL_ID)
200
- # On ZeroGPU Spaces, patch the backend to use the @spaces.GPU wrapper so
201
- # GPU memory is properly allocated per inference call.
202
  if HF_SPACES:
203
- import asyncio
204
- import time as _time
205
 
206
- from hearthnet.services.llm.backends.base import ChatResult
207
- from hearthnet.services.llm.backends.hf_local import _trim_generated
208
 
209
  @_spaces.GPU(duration=120)
210
- def _gpu_pipeline_call(
211
- pipeline, prompt: str, max_new_tokens: int, temperature: float
212
- ) -> list:
213
- """GPU-wrapped pipeline call. ZeroGPU allocates GPU for this function."""
214
- return pipeline(
215
- prompt,
216
- max_new_tokens=max_new_tokens,
217
- temperature=temperature,
218
- do_sample=True,
219
- return_full_text=False,
220
- )
221
 
222
- # Store the GPU wrapper on the backend so it can be replaced without
223
- # changing the public API.
224
- backend._gpu_pipeline_call = _gpu_pipeline_call # type: ignore[attr-defined]
225
-
226
- async def _patched_chat(
227
- self,
228
- messages: list[dict],
229
- *,
230
- model: str = "",
231
- stream: bool = False,
232
- temperature: float = 0.7,
233
- max_tokens: int = 256,
234
- **kwargs,
235
- ):
236
- if self._pipeline is None:
237
- await self.warm()
238
- if self._pipeline is None:
239
- raise RuntimeError("HF model not loaded")
240
- t0 = _time.monotonic()
241
- prompt = self._build_prompt(messages)
242
- loop = asyncio.get_running_loop()
243
- result = await loop.run_in_executor(
244
- None,
245
- lambda: self._gpu_pipeline_call(
246
- self._pipeline, prompt, max_tokens, temperature
247
- ),
248
- )
249
- raw = result[0]["generated_text"] if result else ""
250
- text = _trim_generated(raw)
251
- ms = int((_time.monotonic() - t0) * 1000)
252
- return ChatResult(
253
- text=text,
254
- tokens_in=len(prompt.split()),
255
- tokens_out=len(text.split()),
256
- model=self._model_name,
257
- ms=ms,
258
- )
259
-
260
- HfLocalBackend.chat = _patched_chat # type: ignore[method-assign]
261
 
262
  backends: list = [backend]
263
  # ── Sponsor cloud backends (opt-in via env) ───────────────────────
@@ -551,6 +502,13 @@ _ui = _build_ui(
551
 
552
  demo = _ui.build()
553
 
 
 
 
 
 
 
 
554
  # ── Serve webagent at /webagent/ ──────────────────────────────────────────────
555
  # HF Space enables Gradio SSR mode (GRADIO_SSR_MODE=true), where a Node.js layer
556
  # intercepts ALL requests before Python/FastAPI sees them, making StaticFiles
@@ -688,4 +646,6 @@ if __name__ == "__main__":
688
  server_name="0.0.0.0",
689
  server_port=_port,
690
  ssr_mode=False,
 
 
691
  )
 
13
 
14
  Difference between this Space and a local install
15
  ──────────────────────────────────────────────────
16
+ HF Space → single node, no real peer mesh, MiniCPM5-1B for LLM
17
  Local node → full peer mesh, any LLM backend (Ollama / llama.cpp / HF),
18
  file sharing, multi-node chat, hardware acceleration
19
 
 
46
  # Bootstrap a real HearthNet node
47
  # ─────────────────────────────────────────────────────────────────────────────
48
 
49
+ MODEL_ID = os.getenv("MODEL_ID", "openbmb/MiniCPM5-1B")
50
  MODEL_REVISION = os.getenv("MODEL_REVISION") or None
51
 
52
  SEED_CORPUS = [
 
161
  def _build_node():
162
  """Bootstrap the HearthNet node for this Space.
163
 
164
+ Uses HfLocalBackend (MiniCPM5-1B by default) so inference works without Ollama.
165
  Falls back to _UnavailableBackend if transformers is not installed.
166
  """
167
  import hashlib
 
194
  community_id="ed25519:hf-space-community",
195
  )
196
 
197
+ # LLM — HF Transformers backend (MiniCPM5-1B by default)
198
  try:
199
  backend = HfLocalBackend(model=MODEL_ID)
200
+ # On ZeroGPU Spaces, wrap _generate_sync with @spaces.GPU so CUDA is
201
+ # allocated for exactly the duration of one generation call.
202
  if HF_SPACES:
203
+ from hearthnet.services.llm.backends.hf_local import HfLocalBackend as _HfLocalBackend
 
204
 
205
+ _orig_generate_sync = _HfLocalBackend._generate_sync
 
206
 
207
  @_spaces.GPU(duration=120)
208
+ def _gpu_generate_sync(self, messages, max_tokens=256, temperature=0.7):
209
+ return _orig_generate_sync(self, messages, max_tokens=max_tokens, temperature=temperature)
 
 
 
 
 
 
 
 
 
210
 
211
+ _HfLocalBackend._generate_sync = _gpu_generate_sync # type: ignore[method-assign]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  backends: list = [backend]
214
  # ── Sponsor cloud backends (opt-in via env) ───────────────────────
 
502
 
503
  demo = _ui.build()
504
 
505
+ # Gradio 6 moved theme/css from gr.Blocks() to launch(). Set them directly on the
506
+ # demo object so HF Spaces' auto-launch (which we don't control) picks them up.
507
+ if _ui.theme is not None and hasattr(demo, "theme"):
508
+ demo.theme = _ui.theme
509
+ if _ui.css is not None and hasattr(demo, "css"):
510
+ demo.css = _ui.css
511
+
512
  # ── Serve webagent at /webagent/ ──────────────────────────────────────────────
513
  # HF Space enables Gradio SSR mode (GRADIO_SSR_MODE=true), where a Node.js layer
514
  # intercepts ALL requests before Python/FastAPI sees them, making StaticFiles
 
646
  server_name="0.0.0.0",
647
  server_port=_port,
648
  ssr_mode=False,
649
+ theme=_ui.theme,
650
+ css=_ui.css,
651
  )
hearthnet/services/llm/backends/hf_local.py CHANGED
@@ -1,20 +1,26 @@
1
  """Local HuggingFace Transformers backend.
2
 
3
- ZeroGPU note: When running on HF Spaces with ZeroGPU, CUDA must only be
4
- accessed inside a ``@spaces.GPU``-decorated function. This backend detects
5
- the ``SPACE_HOST`` environment variable and forces CPU (``device=-1``) to
6
- avoid triggering ``torch._C._cuda_init`` at load time. GPU acceleration
7
- within the Space would require wrapping inference in ``@spaces.GPU``.
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
  import os
 
13
 
14
  from hearthnet.services.llm.backends.base import BackendModel, ChatResult
15
  from hearthnet.services.llm.tokenizers import model_family
16
 
17
- # If running on HF Space, force CPU to avoid ZeroGPU CUDA-init errors
18
  _ON_HF_SPACE: bool = bool(os.getenv("SPACE_HOST"))
19
 
20
 
@@ -23,13 +29,7 @@ def _family(model_name: str) -> str:
23
 
24
 
25
  def _content_to_text(content) -> str:
26
- """Coerce a message ``content`` field to a plain string.
27
-
28
- Gradio (type="messages") and multimodal formats can deliver content as a
29
- list/dict such as ``[{'text': '...'}]``. Without this, ``f"{content}"``
30
- would embed that structure verbatim into the prompt — which the model then
31
- echoes back (the ``[{'text': ...}]`` artefact seen in live output).
32
- """
33
  if content is None:
34
  return ""
35
  if isinstance(content, str):
@@ -48,11 +48,7 @@ def _content_to_text(content) -> str:
48
 
49
 
50
  def _trim_generated(text: str) -> str:
51
- """Strip role-echo / hallucinated extra turns from small-model output.
52
-
53
- Tiny instruct models often keep generating a fake ``\\nuser:`` /
54
- ``\\nassistant:`` turn after their answer. Cut at the first such marker.
55
- """
56
  if not text:
57
  return ""
58
  for marker in (
@@ -75,16 +71,17 @@ def _trim_generated(text: str) -> str:
75
  class HfLocalBackend:
76
  name = "hf_local"
77
 
78
- def __init__(self, model: str = "microsoft/DialoGPT-small", device: str = "auto") -> None:
79
  self._model_name = model
80
- # Force CPU on HF Spaces to prevent ZeroGPU CUDA-init outside @spaces.GPU
81
  self._device = "cpu" if _ON_HF_SPACE else device
82
- self._pipeline = None
 
83
  self.models = [
84
  BackendModel(
85
  name=model,
86
  family=_family(model),
87
- context_length=2048,
88
  requires_internet=False,
89
  )
90
  ]
@@ -92,7 +89,6 @@ class HfLocalBackend:
92
  def is_available(self) -> bool:
93
  try:
94
  import transformers # noqa: F401
95
-
96
  return True
97
  except ImportError:
98
  return False
@@ -101,45 +97,47 @@ class HfLocalBackend:
101
  if not self.is_available():
102
  return
103
  import asyncio
104
-
105
  loop = asyncio.get_running_loop()
106
  await loop.run_in_executor(None, self._load)
107
 
108
  def _load(self) -> None:
109
- from transformers import pipeline
110
-
111
- if self._device == "cpu":
112
- device = -1
113
- elif self._device == "cuda":
114
- device = 0
 
 
115
  else:
116
- # "auto" — safe CUDA check (only reaches here when NOT on HF Space)
117
- device = -1
118
- try:
119
- import torch
120
 
121
- device = 0 if torch.cuda.is_available() else -1
122
- except ImportError:
123
- pass
124
- self._pipeline = pipeline(
125
- "text-generation",
126
- model=self._model_name,
127
- device=device,
128
- # Disable auto device_map to keep explicit CPU/GPU control
129
- # Add trust_remote_code=True for models with custom modeling code (e.g. MiniCPM3-4B)
130
- model_kwargs={"low_cpu_mem_usage": True},
131
- tokenizer_kwargs={},
132
  trust_remote_code=True,
133
  )
 
 
134
 
135
- def _build_prompt(self, messages: list[dict]) -> str:
136
- """Render *messages* into a model prompt.
 
 
 
 
 
137
 
138
- Prefers the tokenizer's chat template (correct special tokens, far less
139
- role-echo on small instruct models). Falls back to a plain
140
- ``role: content`` transcript. Content is always coerced to a string so
141
- structured content (e.g. ``[{'text': ...}]``) never leaks in verbatim.
142
  """
 
 
143
  norm = [
144
  {
145
  "role": str(m.get("role", "user")),
@@ -147,15 +145,58 @@ class HfLocalBackend:
147
  }
148
  for m in messages
149
  ]
150
- tokenizer = getattr(self._pipeline, "tokenizer", None)
151
- if tokenizer is not None and getattr(tokenizer, "chat_template", None):
 
 
152
  try:
153
- return tokenizer.apply_chat_template(
154
  norm, tokenize=False, add_generation_prompt=True
155
  )
156
  except Exception:
157
- pass
158
- return "\n".join(f"{m['role']}: {m['content']}" for m in norm) + "\nassistant:"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  async def chat(
161
  self,
@@ -170,29 +211,24 @@ class HfLocalBackend:
170
  import asyncio
171
  import time
172
 
173
- if self._pipeline is None:
174
  await self.warm()
175
- if self._pipeline is None:
176
  raise RuntimeError("HF model not loaded")
 
177
  t0 = time.monotonic()
178
- prompt = self._build_prompt(messages)
179
  loop = asyncio.get_running_loop()
180
- result = await loop.run_in_executor(
 
181
  None,
182
- lambda: self._pipeline(
183
- prompt,
184
- max_new_tokens=max_tokens,
185
- temperature=temperature,
186
- do_sample=True,
187
- return_full_text=False,
188
  ),
189
  )
190
- raw = result[0]["generated_text"] if result else ""
191
- text = _trim_generated(raw)
192
  ms = int((time.monotonic() - t0) * 1000)
193
  return ChatResult(
194
  text=text,
195
- tokens_in=len(prompt.split()),
196
  tokens_out=len(text.split()),
197
  model=self._model_name,
198
  ms=ms,
@@ -204,13 +240,14 @@ class HfLocalBackend:
204
  )
205
 
206
  async def close(self) -> None:
207
- self._pipeline = None
 
208
 
209
  def health(self) -> dict:
210
  return {
211
  "backend": "hf_local",
212
  "model": self._model_name,
213
- "loaded": self._pipeline is not None,
214
  "device": self._device,
215
  "on_hf_space": _ON_HF_SPACE,
216
  }
 
1
  """Local HuggingFace Transformers backend.
2
 
3
+ Follows the OpenBMB MiniCPM demo pattern: AutoModelForCausalLM +
4
+ TextIteratorStreamer + threading.Thread instead of pipeline().
5
+
6
+ Why not pipeline():
7
+ The transformers pipeline() abstraction can internally trigger Python pickle
8
+ when combined with trust_remote_code=True models (their dynamically-loaded
9
+ classes are not picklable). Using model.generate() directly with
10
+ threading.Thread avoids any serialisation — threads share memory.
11
+
12
+ ZeroGPU note: On HF Spaces, app.py wraps _generate_sync with @spaces.GPU so
13
+ CUDA is only accessed inside the ZeroGPU-allocated window.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
  import os
19
+ import threading
20
 
21
  from hearthnet.services.llm.backends.base import BackendModel, ChatResult
22
  from hearthnet.services.llm.tokenizers import model_family
23
 
 
24
  _ON_HF_SPACE: bool = bool(os.getenv("SPACE_HOST"))
25
 
26
 
 
29
 
30
 
31
  def _content_to_text(content) -> str:
32
+ """Coerce a message content field to a plain string."""
 
 
 
 
 
 
33
  if content is None:
34
  return ""
35
  if isinstance(content, str):
 
48
 
49
 
50
  def _trim_generated(text: str) -> str:
51
+ """Strip role-echo / hallucinated extra turns from small-model output."""
 
 
 
 
52
  if not text:
53
  return ""
54
  for marker in (
 
71
  class HfLocalBackend:
72
  name = "hf_local"
73
 
74
+ def __init__(self, model: str = "openbmb/MiniCPM5-1B", device: str = "auto") -> None:
75
  self._model_name = model
76
+ # Force CPU on HF Spaces ZeroGPU allocates CUDA only inside @spaces.GPU
77
  self._device = "cpu" if _ON_HF_SPACE else device
78
+ self._model = None
79
+ self._tokenizer = None
80
  self.models = [
81
  BackendModel(
82
  name=model,
83
  family=_family(model),
84
+ context_length=8192,
85
  requires_internet=False,
86
  )
87
  ]
 
89
  def is_available(self) -> bool:
90
  try:
91
  import transformers # noqa: F401
 
92
  return True
93
  except ImportError:
94
  return False
 
97
  if not self.is_available():
98
  return
99
  import asyncio
 
100
  loop = asyncio.get_running_loop()
101
  await loop.run_in_executor(None, self._load)
102
 
103
  def _load(self) -> None:
104
+ import torch
105
+ from transformers import AutoModelForCausalLM, AutoTokenizer
106
+
107
+ if self._device == "cuda" or (
108
+ self._device == "auto" and torch.cuda.is_available()
109
+ ):
110
+ dtype = torch.bfloat16
111
+ target_device = "cuda"
112
  else:
113
+ dtype = torch.float32
114
+ target_device = "cpu"
 
 
115
 
116
+ self._tokenizer = AutoTokenizer.from_pretrained(
117
+ self._model_name, trust_remote_code=True
118
+ )
119
+ self._model = AutoModelForCausalLM.from_pretrained(
120
+ self._model_name,
121
+ torch_dtype=dtype,
 
 
 
 
 
122
  trust_remote_code=True,
123
  )
124
+ if target_device != "cpu":
125
+ self._model = self._model.to(target_device)
126
 
127
+ def _generate_sync(
128
+ self,
129
+ messages: list[dict],
130
+ max_tokens: int = 256,
131
+ temperature: float = 0.7,
132
+ ) -> str:
133
+ """Run generation synchronously (call from a thread, not the event loop).
134
 
135
+ Uses TextIteratorStreamer + threading.Thread following the OpenBMB demo
136
+ pattern model.generate() runs in a daemon thread while this thread
137
+ drains the streamer. No pickle required.
 
138
  """
139
+ from transformers import TextIteratorStreamer
140
+
141
  norm = [
142
  {
143
  "role": str(m.get("role", "user")),
 
145
  }
146
  for m in messages
147
  ]
148
+
149
+ # Prefer chat template; fall back to plain transcript
150
+ tokenizer = self._tokenizer
151
+ if getattr(tokenizer, "chat_template", None):
152
  try:
153
+ prompt_text = tokenizer.apply_chat_template(
154
  norm, tokenize=False, add_generation_prompt=True
155
  )
156
  except Exception:
157
+ prompt_text = (
158
+ "\n".join(f"{m['role']}: {m['content']}" for m in norm)
159
+ + "\nassistant:"
160
+ )
161
+ else:
162
+ prompt_text = (
163
+ "\n".join(f"{m['role']}: {m['content']}" for m in norm)
164
+ + "\nassistant:"
165
+ )
166
+
167
+ device = next(self._model.parameters()).device
168
+ model_inputs = tokenizer([prompt_text], return_tensors="pt")
169
+ model_inputs = {k: v.to(device) for k, v in model_inputs.items()}
170
+
171
+ streamer = TextIteratorStreamer(
172
+ tokenizer,
173
+ skip_prompt=True,
174
+ skip_special_tokens=True,
175
+ )
176
+
177
+ gen_kwargs: dict = dict(
178
+ **model_inputs,
179
+ streamer=streamer,
180
+ max_new_tokens=max_tokens,
181
+ )
182
+ if temperature > 0:
183
+ gen_kwargs.update(temperature=temperature, do_sample=True)
184
+ else:
185
+ gen_kwargs["do_sample"] = False
186
+
187
+ # model.generate runs in its own thread; this thread drains the streamer
188
+ gen_thread = threading.Thread(
189
+ target=self._model.generate, kwargs=gen_kwargs, daemon=True
190
+ )
191
+ gen_thread.start()
192
+
193
+ full_text = ""
194
+ for token_text in streamer:
195
+ if token_text:
196
+ full_text += token_text
197
+
198
+ gen_thread.join(timeout=120)
199
+ return _trim_generated(full_text)
200
 
201
  async def chat(
202
  self,
 
211
  import asyncio
212
  import time
213
 
214
+ if self._model is None:
215
  await self.warm()
216
+ if self._model is None:
217
  raise RuntimeError("HF model not loaded")
218
+
219
  t0 = time.monotonic()
 
220
  loop = asyncio.get_running_loop()
221
+ # Run _generate_sync in a thread — no pickling, threads share memory
222
+ text = await loop.run_in_executor(
223
  None,
224
+ lambda: self._generate_sync(
225
+ messages, max_tokens=max_tokens, temperature=temperature
 
 
 
 
226
  ),
227
  )
 
 
228
  ms = int((time.monotonic() - t0) * 1000)
229
  return ChatResult(
230
  text=text,
231
+ tokens_in=0,
232
  tokens_out=len(text.split()),
233
  model=self._model_name,
234
  ms=ms,
 
240
  )
241
 
242
  async def close(self) -> None:
243
+ self._model = None
244
+ self._tokenizer = None
245
 
246
  def health(self) -> dict:
247
  return {
248
  "backend": "hf_local",
249
  "model": self._model_name,
250
+ "loaded": self._model is not None,
251
  "device": self._device,
252
  "on_hf_space": _ON_HF_SPACE,
253
  }
hearthnet/ui/app.py CHANGED
@@ -188,6 +188,8 @@ class UiApp:
188
  self._node = node
189
  self._meta = meta
190
  self._demo = None
 
 
191
 
192
  def build(self) -> Any:
193
  """Build and return the Gradio Blocks app."""
@@ -247,11 +249,12 @@ class UiApp:
247
  .gr-button-primary:hover { transform: translateY(-1px);
248
  box-shadow: 0 4px 12px rgba(124,58,237,.4); }
249
  """
 
 
 
250
 
251
  with gr.Blocks(
252
  title=f"HearthNet — {display_name}",
253
- theme=hearthnet_theme,
254
- css=_css,
255
  ) as demo:
256
  # Easter egg ticker + agent modal via Gradio 6 js_on_load API
257
  gr.HTML(html_template=_EGG_HTML, js_on_load=_EGG_JS)
 
188
  self._node = node
189
  self._meta = meta
190
  self._demo = None
191
+ self.theme = None
192
+ self.css = None
193
 
194
  def build(self) -> Any:
195
  """Build and return the Gradio Blocks app."""
 
249
  .gr-button-primary:hover { transform: translateY(-1px);
250
  box-shadow: 0 4px 12px rgba(124,58,237,.4); }
251
  """
252
+ # Store for caller to pass to demo.launch() (Gradio 6 moved theme/css there)
253
+ self.theme = hearthnet_theme
254
+ self.css = _css
255
 
256
  with gr.Blocks(
257
  title=f"HearthNet — {display_name}",
 
 
258
  ) as demo:
259
  # Easter egg ticker + agent modal via Gradio 6 js_on_load API
260
  gr.HTML(html_template=_EGG_HTML, js_on_load=_EGG_JS)