JG1310 commited on
Commit
1024abf
·
verified ·
1 Parent(s): 115ce84

promote ZeroGPU local-first build: Nemotron via llama.cpp + FLUX.2 via diffusers

Browse files
app.py CHANGED
@@ -11,6 +11,15 @@ CPU box (no torch/diffusers), consistent with Globe.
11
  """
12
  from __future__ import annotations
13
 
 
 
 
 
 
 
 
 
 
14
  import base64
15
  import html
16
  import json
@@ -26,12 +35,32 @@ from fastapi.staticfiles import StaticFiles
26
 
27
  from arcana.build import (DECKS_DIR, build_deck, deck_dir, list_decks, load_deck,
28
  save_deck, slugify)
 
29
  from arcana.reader import (SPREADS, card_partial, draw_spread, final_synthesis)
30
  from arcana.styles import CUSTOM_PLACEHOLDER, STYLE_CHOICES
31
 
32
  ROOT = os.path.dirname(os.path.abspath(__file__))
33
  DEFAULT_THEME = "thermodynamics"
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
36
  "Name one, and I shall draw out its fortune.")
37
  EXAMPLE_THEMES = ["Physics", "Birds", "Lord of the Rings Characters", "Breakfast Foods"]
@@ -45,7 +74,10 @@ def _abs(art_path: str | None):
45
 
46
 
47
  def url_for(art_path: str | None) -> str:
48
- return ("/" + art_path) if art_path else ""
 
 
 
49
 
50
 
51
  def disp_url(c: dict) -> str:
@@ -58,11 +90,16 @@ def full_url(c: dict) -> str:
58
  return url_for(c.get("full_path") or c.get("art_path"))
59
 
60
 
 
 
 
 
61
  def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
62
- """The deck browser — a self-contained coverflow/overhead widget (served at
63
- /viewer/deckview.html) embedded in an iframe, with the deck's image URLs +
64
- names injected as base64 JSON. All swipe/flip/fade/toggle/lightbox is handled
65
- client-side in the widget (smooth, no server round-trips)."""
 
66
  if not deck or not deck.get("cards"):
67
  return "<div class='spread-empty'>No deck to show.</div>"
68
  data = {
@@ -72,8 +109,10 @@ def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
72
  for c in deck["cards"]],
73
  }
74
  b64 = base64.b64encode(json.dumps(data).encode("utf-8")).decode()
75
- src = f"/viewer/deckview.html?d={b64}&n={nonce}"
76
- return (f"<iframe src='{html.escape(src)}' title='Deck' "
 
 
77
  "style='width:100%;height:74vh;min-height:520px;border:0;display:block;"
78
  "background:transparent;'></iframe>")
79
 
@@ -177,36 +216,53 @@ def _next_btn(label: str | None):
177
  return gr.update(value=label, visible=True) if label else gr.update(visible=False)
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  def _reveal_card(sess):
181
- """Flip the card at sess['i'] into the active area and stream its reading.
182
- Yields (stage_html, sess, next_button_update)."""
183
- deck, drawn, back, i = sess["deck"], sess["drawn"], sess["back"], sess["i"]
184
  d, completed = drawn[i], sess["completed"]
185
  label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
186
- # flip + essence AND Next show immediately the reader is never stuck waiting
187
- # on the model (which can be slow/overloaded); the deeper reading fades in after.
188
  yield _stage(back, completed, active=(d, "", True)), sess, _next_btn(label)
189
  time.sleep(FLIP_BEAT)
190
- try:
191
- p = card_partial(deck, sess["question"], drawn, i)
192
- except Exception:
193
- p = "" # model unavailable — the essence above stands as the reading
194
  sess["last_p"] = p
195
- sess["partials"].append(p)
196
  body = _fade(p) if p else ""
197
  yield _stage(back, completed, active=(d, body, False)), sess, _next_btn(label)
198
 
199
 
200
  def do_draw(deck, question, spread, reversals):
201
- """Begin a reading: draw the spread and reveal the first card. From here the
202
- reader clicks Next to advance nothing auto-advances."""
203
  if not deck:
204
  yield "<div class='spread-empty'>Conjure or open a deck first.</div>", None, _next_btn(None)
205
  return
206
  drawn = draw_spread(deck, spread=spread, reversals=reversals)
 
 
 
207
  sess = {"deck": deck, "drawn": drawn, "question": question,
208
  "back": deck.get("back_disp") or deck.get("back_path"), "i": 0, "completed": [],
209
- "partials": [], "last_p": "", "phase": "card"}
210
  yield from _reveal_card(sess)
211
 
212
 
@@ -230,10 +286,8 @@ def do_next(sess):
230
  yield (_stage(back, sess["completed"],
231
  synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
232
  sess, _next_btn(None))
233
- try:
234
- synth = final_synthesis(sess["deck"], sess["question"], sess["drawn"], sess["partials"])
235
- except Exception as e:
236
- synth = f"(the synthesis slips away — {type(e).__name__})"
237
  sess["phase"] = "done"
238
  yield (_stage(back, sess["completed"],
239
  synth_html=f"<h3>✦ The cards together</h3><div class='syn-body'>{_fade(synth)}</div>"),
@@ -254,6 +308,26 @@ def do_conjure(theme, style, custom):
254
  theme = (theme or "").strip()
255
  if not theme:
256
  yield stay("Name a theme to begin."); return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  st = {"msg": "Consulting the deck designer…", "frac": 0.0,
258
  "done": False, "deck": None, "err": None}
259
 
@@ -268,17 +342,13 @@ def do_conjure(theme, style, custom):
268
 
269
  threading.Thread(target=run, daemon=True).start()
270
  while not st["done"]:
271
- pct = int(min(st["frac"], 0.99) * 100)
272
- yield stay(f"### ✦ {st['msg']}\n\n**{pct}%** — conjuring *{theme}*")
273
  time.sleep(0.4)
274
-
275
  if st["err"] or not st["deck"]:
276
- why = type(st["err"]).__name__ if st["err"] else "no deck"
277
- yield stay(f"⚠️ The conjuring faltered ({why}). Try again."); return
278
-
279
- deck = st["deck"]
280
- # open the freshly-made deck in the coverflow deck browser
281
- yield ("", deck, deck, deck_iframe(deck, _next_nonce()), zip_deck(deck), *show("view"))
282
 
283
 
284
  def toggle_custom(style):
@@ -671,19 +741,16 @@ def build_demo() -> gr.Blocks:
671
  return demo
672
 
673
 
674
- def build_app() -> FastAPI:
675
- app = FastAPI()
676
- os.makedirs(DECKS_DIR, exist_ok=True)
677
- app.mount("/decks", StaticFiles(directory=DECKS_DIR), name="decks")
678
- app.mount("/viewer", StaticFiles(directory=os.path.join(ROOT, "frontend")), name="viewer")
679
- demo = build_demo()
680
- demo.queue(default_concurrency_limit=4)
681
- return gr.mount_gradio_app(app, demo, path="/", css=CSS,
682
- theme=gr.themes.Soft(), ssr_mode=False)
683
-
684
-
685
- app = build_app()
686
 
 
 
 
 
 
687
  if __name__ == "__main__":
688
- import uvicorn
689
- uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
 
 
11
  """
12
  from __future__ import annotations
13
 
14
+ # IMPORT ORDER MATTERS: `spaces` must be imported BEFORE gradio (and before torch)
15
+ # so it can patch Gradio for ZeroGPU. If gradio loads first, ZeroGPU never
16
+ # initialises and the Space boots then immediately gets SIGTERM ("Shutting down").
17
+ try:
18
+ import spaces
19
+ _HAS_SPACES = True
20
+ except Exception:
21
+ _HAS_SPACES = False
22
+
23
  import base64
24
  import html
25
  import json
 
35
 
36
  from arcana.build import (DECKS_DIR, build_deck, deck_dir, list_decks, load_deck,
37
  save_deck, slugify)
38
+ from arcana.llm import get_llm
39
  from arcana.reader import (SPREADS, card_partial, draw_spread, final_synthesis)
40
  from arcana.styles import CUSTOM_PLACEHOLDER, STYLE_CHOICES
41
 
42
  ROOT = os.path.dirname(os.path.abspath(__file__))
43
  DEFAULT_THEME = "thermodynamics"
44
 
45
+ # ---- ZeroGPU (local-first build) ------------------------------------------
46
+ # When LLM/IMAGE backends are local, the heavy work runs on the Space's GPU and
47
+ # must be wrapped in @spaces.GPU. Off-Space (or endpoint mode) @GPU is a no-op.
48
+ LOCAL_GPU = (os.environ.get("LLM_BACKEND", "").lower() == "local"
49
+ or os.environ.get("IMAGE_BACKEND", "").lower() == "local")
50
+ if _HAS_SPACES:
51
+ def GPU(duration=120):
52
+ return spaces.GPU(duration=duration)
53
+ else:
54
+ def GPU(duration=120):
55
+ def _deco(fn):
56
+ return fn
57
+ return _deco
58
+
59
+
60
+ # NOTE: no startup prefetch — downloading the 24GB GGUF in a startup thread made
61
+ # the Space crash on boot (and is unnecessary: the in-@spaces.GPU download path is
62
+ # fast, ~20s). Models load lazily on the first GPU call and cache on disk after.
63
+
64
  WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
65
  "Name one, and I shall draw out its fortune.")
66
  EXAMPLE_THEMES = ["Physics", "Birds", "Lord of the Rings Characters", "Breakfast Foods"]
 
74
 
75
 
76
  def url_for(art_path: str | None) -> str:
77
+ # Served by Gradio's own static-file route (demo.launch + allowed_paths). This
78
+ # replaces the FastAPI /decks mount, which is incompatible with ZeroGPU (ZeroGPU
79
+ # needs gradio's native launch, not a custom uvicorn-mounted app).
80
+ return f"/gradio_api/file={_abs(art_path)}" if art_path else ""
81
 
82
 
83
  def disp_url(c: dict) -> str:
 
90
  return url_for(c.get("full_path") or c.get("art_path"))
91
 
92
 
93
+ with open(os.path.join(ROOT, "frontend", "deckview.html"), encoding="utf-8") as _f:
94
+ _DECKVIEW_HTML = _f.read()
95
+
96
+
97
  def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
98
+ """The deck browser — a self-contained coverflow/overhead widget embedded via an
99
+ iframe ``srcdoc`` (the whole widget HTML is inlined, with the deck's image URLs +
100
+ names injected as base64 JSON in a global). Using srcdoc instead of a /viewer
101
+ static mount keeps everything inside gradio's native server (ZeroGPU-compatible).
102
+ All swipe/flip/fade/toggle/lightbox is handled client-side in the widget."""
103
  if not deck or not deck.get("cards"):
104
  return "<div class='spread-empty'>No deck to show.</div>"
105
  data = {
 
109
  for c in deck["cards"]],
110
  }
111
  b64 = base64.b64encode(json.dumps(data).encode("utf-8")).decode()
112
+ inject = f"<script>window.DECK_B64={json.dumps(b64)};</script>"
113
+ doc = _DECKVIEW_HTML.replace("</head>", inject + "</head>", 1)
114
+ srcdoc = html.escape(doc, quote=True)
115
+ return (f"<iframe srcdoc='{srcdoc}' title='Deck' "
116
  "style='width:100%;height:74vh;min-height:520px;border:0;display:block;"
117
  "background:transparent;'></iframe>")
118
 
 
216
  return gr.update(value=label, visible=True) if label else gr.update(visible=False)
217
 
218
 
219
+ @GPU(duration=300)
220
+ def _gpu_reading(deck, question, drawn):
221
+ """Compute ALL card readings + the synthesis in ONE GPU session (the Nemotron
222
+ GGUF can't be cached across separate ZeroGPU calls). Revealed progressively
223
+ client-side afterwards. Returns (partials, synthesis)."""
224
+ llm = get_llm()
225
+ partials = []
226
+ for i in range(len(drawn)):
227
+ try:
228
+ partials.append(card_partial(deck, question, drawn, i, llm=llm))
229
+ except Exception:
230
+ partials.append("")
231
+ try:
232
+ synth = final_synthesis(deck, question, drawn, partials, llm=llm)
233
+ except Exception as e:
234
+ synth = f"(the synthesis slips away — {type(e).__name__})"
235
+ return partials, synth
236
+
237
+
238
  def _reveal_card(sess):
239
+ """Flip the card at sess['i'] into the active area and reveal its (already
240
+ computed) reading. Yields (stage_html, sess, next_button_update)."""
241
+ drawn, back, i = sess["drawn"], sess["back"], sess["i"]
242
  d, completed = drawn[i], sess["completed"]
243
  label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
244
+ # flip + essence show first, then the deeper reading fades in a beat later
 
245
  yield _stage(back, completed, active=(d, "", True)), sess, _next_btn(label)
246
  time.sleep(FLIP_BEAT)
247
+ p = sess["partials_pre"][i] if i < len(sess.get("partials_pre", [])) else ""
 
 
 
248
  sess["last_p"] = p
 
249
  body = _fade(p) if p else ""
250
  yield _stage(back, completed, active=(d, body, False)), sess, _next_btn(label)
251
 
252
 
253
  def do_draw(deck, question, spread, reversals):
254
+ """Begin a reading: draw the spread, compute every card's reading on the GPU in
255
+ one pass, then reveal card by card as the reader clicks Next."""
256
  if not deck:
257
  yield "<div class='spread-empty'>Conjure or open a deck first.</div>", None, _next_btn(None)
258
  return
259
  drawn = draw_spread(deck, spread=spread, reversals=reversals)
260
+ yield ("<div class='spread-empty'>…the oracle contemplates the spread…</div>",
261
+ None, _next_btn(None))
262
+ partials, synth = _gpu_reading(deck, question, drawn)
263
  sess = {"deck": deck, "drawn": drawn, "question": question,
264
  "back": deck.get("back_disp") or deck.get("back_path"), "i": 0, "completed": [],
265
+ "partials_pre": partials, "synth_pre": synth, "last_p": "", "phase": "card"}
266
  yield from _reveal_card(sess)
267
 
268
 
 
286
  yield (_stage(back, sess["completed"],
287
  synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
288
  sess, _next_btn(None))
289
+ time.sleep(FLIP_BEAT)
290
+ synth = sess.get("synth_pre") or "(the synthesis slips away)"
 
 
291
  sess["phase"] = "done"
292
  yield (_stage(back, sess["completed"],
293
  synth_html=f"<h3>✦ The cards together</h3><div class='syn-body'>{_fade(synth)}</div>"),
 
308
  theme = (theme or "").strip()
309
  if not theme:
310
  yield stay("Name a theme to begin."); return
311
+ deck = None
312
+ for kind, payload, frac in _gpu_build(theme, style, custom):
313
+ if kind == "progress":
314
+ yield stay(f"### ✦ {payload}\n\n**{int(frac * 100)}%** — conjuring *{theme}*")
315
+ elif kind == "error":
316
+ yield stay(f"⚠️ The conjuring faltered ({payload}). Try again."); return
317
+ elif kind == "done":
318
+ deck = payload
319
+ if not deck:
320
+ yield stay("⚠️ The conjuring faltered (no deck). Try again."); return
321
+ # open the freshly-made deck in the coverflow deck browser
322
+ yield ("", deck, deck, deck_iframe(deck, _next_nonce()), zip_deck(deck), *show("view"))
323
+
324
+
325
+ @GPU(duration=600)
326
+ def _gpu_build(theme, style, custom):
327
+ """Build the whole deck (local Nemotron mapping + local FLUX.2 art) in ONE GPU
328
+ session, streaming (kind, payload, frac) progress events. The build runs in a
329
+ thread so this generator can keep yielding progress; the thread shares the
330
+ process's attached GPU."""
331
  st = {"msg": "Consulting the deck designer…", "frac": 0.0,
332
  "done": False, "deck": None, "err": None}
333
 
 
342
 
343
  threading.Thread(target=run, daemon=True).start()
344
  while not st["done"]:
345
+ yield ("progress", st["msg"], min(st["frac"], 0.99))
 
346
  time.sleep(0.4)
 
347
  if st["err"] or not st["deck"]:
348
+ why = f"{type(st['err']).__name__}: {st['err']}" if st["err"] else "no deck"
349
+ yield ("error", why[:300], 0.0)
350
+ else:
351
+ yield ("done", st["deck"], 1.0)
 
 
352
 
353
 
354
  def toggle_custom(style):
 
741
  return demo
742
 
743
 
744
+ os.makedirs(DECKS_DIR, exist_ok=True)
745
+ demo = build_demo()
746
+ demo.queue(default_concurrency_limit=4)
 
 
 
 
 
 
 
 
 
747
 
748
+ # ZeroGPU requires gradio's NATIVE launch — a custom uvicorn-mounted FastAPI app
749
+ # boots then immediately gets SIGTERM (ZeroGPU never registers). Card images are
750
+ # served via gradio's own static-file route (allowed_paths) instead of a /decks
751
+ # mount; the deck-view widget is inlined via iframe srcdoc instead of a /viewer
752
+ # mount. ssr_mode=False (ssr tries to bind a 2nd port and crashes here).
753
  if __name__ == "__main__":
754
+ demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
755
+ allowed_paths=[DECKS_DIR, os.path.join(ROOT, "frontend")],
756
+ css=CSS, theme=gr.themes.Soft(), ssr_mode=False)
arcana/designer.py CHANGED
@@ -148,23 +148,30 @@ def design_deck(theme: str, llm: LLM | None = None) -> dict:
148
  system = designer_system_prompt()
149
  user = designer_user_prompt(theme)
150
 
 
 
 
 
 
151
  first_err = None
152
- raw = llm.complete(system, user, json_mode=True)
153
- try:
154
- return validate_deck(extract_json(raw), theme)
155
- except DeckError as e:
156
- first_err = e
 
157
 
158
- # one repair attempt, told exactly what was wrong
159
  repair = (
160
  f"{user}\n\nYour previous reply was rejected: {first_err}. "
161
  "Return ONLY the corrected strict JSON object with exactly 22 cards "
162
- "covering arcana 0 through 21, each with non-empty concept, "
163
- "justification, upright_meaning, reversed_meaning and art_prompt. "
164
  "No prose, no code fences."
165
  )
166
  raw2 = llm.complete(system, repair, json_mode=True)
167
  try:
168
  return validate_deck(extract_json(raw2), theme)
169
  except DeckError as e:
170
- raise DeckError(f"deck invalid after repair (first: {first_err}) -> {e}") from e
 
 
148
  system = designer_system_prompt()
149
  user = designer_user_prompt(theme)
150
 
151
+ # Fresh full generations first — structural slips (a duplicated concept, a
152
+ # dropped card) are largely random per draw, so re-rolling beats trying to
153
+ # surgically repair a bad draw (the repair often returns a partial deck).
154
+ import os as _os
155
+ attempts = int(_os.environ.get("DESIGN_ATTEMPTS", "4"))
156
  first_err = None
157
+ for _ in range(attempts):
158
+ try:
159
+ raw = llm.complete(system, user, json_mode=True)
160
+ return validate_deck(extract_json(raw), theme)
161
+ except DeckError as e:
162
+ first_err = first_err or e
163
 
164
+ # last resort: one targeted repair, told exactly what was wrong
165
  repair = (
166
  f"{user}\n\nYour previous reply was rejected: {first_err}. "
167
  "Return ONLY the corrected strict JSON object with exactly 22 cards "
168
+ "covering arcana 0 through 21, each with a DISTINCT non-empty concept, "
169
+ "plus justification, upright_meaning, reversed_meaning and art_prompt. "
170
  "No prose, no code fences."
171
  )
172
  raw2 = llm.complete(system, repair, json_mode=True)
173
  try:
174
  return validate_deck(extract_json(raw2), theme)
175
  except DeckError as e:
176
+ raise DeckError(f"deck invalid after {attempts} tries + repair "
177
+ f"(first: {first_err}) -> {e}") from e
arcana/gpu_models.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """On-Space GPU model singletons for the ZeroGPU (local-first) build.
2
+
3
+ Both heavy models — the Nemotron GGUF (via llama.cpp) and FLUX.2 (via diffusers)
4
+ — are loaded LAZILY and cached process-wide here. They MUST be constructed inside
5
+ an ``@spaces.GPU`` context (that's the only time the GPU is attached on ZeroGPU),
6
+ so nothing here runs at import; the first call from inside a decorated function
7
+ does the load. CUDA must not initialise at import either (the process forks).
8
+
9
+ Hard-won ZeroGPU facts (June 2026, RTX PRO 6000 Blackwell / CUDA 13 / torch cu130):
10
+ * llama-cpp-python must be the **cu130** prebuilt wheel; its libllama.so needs
11
+ the CUDA-13 runtime libs which live under site-packages/nvidia/cu13/lib and
12
+ are NOT on LD_LIBRARY_PATH — preload them RTLD_GLOBAL before importing llama_cpp.
13
+ * The Nemotron GGUF reasons unless the assistant turn is seeded with an empty
14
+ ``<think></think>`` block — so we hand-build a ChatML prompt + create_completion.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import ctypes
19
+ import glob
20
+ import os
21
+
22
+ # fast, robust multi-connection downloads from the Hub (the 24GB GGUF + FLUX
23
+ # weights crawl at ~8MB/s otherwise). Set before any huggingface_hub import.
24
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
25
+
26
+ # ---- model selection (overridable via env) --------------------------------
27
+ # Nemotron via llama.cpp. We use Llama-3.1-Nemotron-Nano-8B (NVIDIA, dense
28
+ # Llama-arch) rather than the 30B-A3B MoE: at Q4 the 30B-MoE reliably left some
29
+ # 'concept' fields EMPTY in the 22-card mapping JSON and took ~5min/deck, whereas
30
+ # the dense 8B follows the structured prompt cleanly and fast. Still a Nemotron
31
+ # model (prize-qualifying) + llama.cpp + local-first.
32
+ LLM_REPO = os.environ.get("LOCAL_LLM_REPO", "bartowski/nvidia_Llama-3.1-Nemotron-Nano-8B-v1-GGUF")
33
+ LLM_FILE = os.environ.get("LOCAL_LLM_FILE", "nvidia_Llama-3.1-Nemotron-Nano-8B-v1-Q4_K_M.gguf")
34
+ IMAGE_MODEL = os.environ.get("LOCAL_IMAGE_MODEL", "black-forest-labs/FLUX.2-klein-base-4B")
35
+ LLM_N_CTX = int(os.environ.get("LOCAL_LLM_N_CTX", "16384"))
36
+
37
+ _llm = None
38
+ _pipe = None
39
+ _cuda_preloaded = False
40
+
41
+
42
+ def _preload_cuda() -> None:
43
+ """Preload CUDA runtime libs so llama.cpp's CUDA .so can dlopen its deps."""
44
+ global _cuda_preloaded
45
+ if _cuda_preloaded:
46
+ return
47
+ names = ["libcudart.so.13", "libcublas.so.13", "libcublasLt.so.13",
48
+ "libcudart.so.12", "libcublas.so.12", "libcublasLt.so.12"]
49
+ roots = ["/usr/local/lib/python3.10/site-packages",
50
+ "/usr/local/lib/python3.11/site-packages"]
51
+ for name in names:
52
+ for root in roots:
53
+ hits = glob.glob(f"{root}/nvidia/*/lib/{name}")
54
+ if hits:
55
+ try:
56
+ ctypes.CDLL(hits[0], mode=ctypes.RTLD_GLOBAL)
57
+ except OSError:
58
+ pass
59
+ break
60
+ _cuda_preloaded = True
61
+
62
+
63
+ def get_llama():
64
+ """Lazily load + cache the Nemotron GGUF on the GPU (call inside @spaces.GPU)."""
65
+ global _llm
66
+ if _llm is None:
67
+ _preload_cuda()
68
+ from huggingface_hub import hf_hub_download
69
+ from llama_cpp import Llama
70
+ path = hf_hub_download(LLM_REPO, LLM_FILE, token=os.environ.get("HF_TOKEN"))
71
+ _llm = Llama(model_path=path, n_gpu_layers=-1, n_ctx=LLM_N_CTX, verbose=False)
72
+ return _llm
73
+
74
+
75
+ def llama_chat(system: str, user: str, json_mode: bool = False,
76
+ max_tokens: int = 4096, temperature: float = 0.7) -> str:
77
+ """Generate via create_chat_completion (applies the GGUF's own chat template —
78
+ correct for the 8B's Llama-3 format). 'detailed thinking off' is Nemotron's
79
+ control to skip the reasoning trace for clean prose; json_mode adds a JSON
80
+ grammar (response_format) so the mapping output is always parseable."""
81
+ llm = get_llama()
82
+ sysmsg = ("detailed thinking off\n\n" + system) if not json_mode else system
83
+ kwargs = dict(
84
+ messages=[{"role": "system", "content": sysmsg},
85
+ {"role": "user", "content": user}],
86
+ max_tokens=max_tokens, temperature=temperature)
87
+ if json_mode:
88
+ kwargs["response_format"] = {"type": "json_object"}
89
+ out = llm.create_chat_completion(**kwargs)
90
+ return (out["choices"][0]["message"]["content"] or "").strip()
91
+
92
+
93
+ def get_pipe():
94
+ """Lazily load + cache the FLUX.2 pipeline on the GPU (call inside @spaces.GPU)."""
95
+ global _pipe
96
+ if _pipe is None:
97
+ import torch
98
+ from diffusers import Flux2KleinPipeline
99
+ _pipe = Flux2KleinPipeline.from_pretrained(
100
+ IMAGE_MODEL, torch_dtype=torch.bfloat16, token=os.environ.get("HF_TOKEN"))
101
+ _pipe.to("cuda")
102
+ return _pipe
arcana/imagegen.py CHANGED
@@ -91,16 +91,34 @@ class EndpointImageGen:
91
 
92
 
93
  class LocalImageGen:
94
- """STRETCH stub — diffusers SDXL/FLUX on a local GPU (SPEC §7, §14)."""
 
 
 
 
 
 
 
 
 
 
95
 
96
  def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
97
- raise NotImplementedError(
98
- "local image backend needs a GPU + diffusers; use the endpoint backend."
99
- )
 
 
 
 
 
 
 
 
100
 
101
 
102
  def get_imagegen(deck_style: str | None = None) -> ImageGen:
103
  backend = os.environ.get("IMAGE_BACKEND", "endpoint").lower()
104
  if backend == "local":
105
- return LocalImageGen()
106
  return EndpointImageGen(deck_style=deck_style)
 
91
 
92
 
93
  class LocalImageGen:
94
+ """FLUX.2 [klein] on the Space's GPU via diffusers (local-first, §7/§14).
95
+
96
+ Generation MUST run inside an ``@spaces.GPU`` context — the pipeline loads on
97
+ first use (see gpu_models.get_pipe). klein-base-4B is sharp at just 4 steps.
98
+ """
99
+
100
+ def __init__(self, deck_style: str | None = None, size: int = 1024,
101
+ steps: int | None = None):
102
+ self.deck_style = deck_style
103
+ self.size = int(os.environ.get("IMAGE_SIZE_PX", size))
104
+ self.steps = int(os.environ.get("IMAGE_STEPS", steps or 4))
105
 
106
  def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
107
+ import torch
108
+ from .gpu_models import get_pipe
109
+ pipe = get_pipe()
110
+ gen = None
111
+ if seed is not None:
112
+ gen = torch.Generator(device="cuda").manual_seed(int(seed))
113
+ img = pipe(prompt=_style(prompt, self.deck_style),
114
+ height=self.size, width=self.size,
115
+ num_inference_steps=self.steps, guidance_scale=4.0,
116
+ generator=gen).images[0]
117
+ return img.convert("RGB")
118
 
119
 
120
  def get_imagegen(deck_style: str | None = None) -> ImageGen:
121
  backend = os.environ.get("IMAGE_BACKEND", "endpoint").lower()
122
  if backend == "local":
123
+ return LocalImageGen(deck_style=deck_style)
124
  return EndpointImageGen(deck_style=deck_style)
arcana/llm.py CHANGED
@@ -132,19 +132,30 @@ class FallbackLLM:
132
 
133
 
134
  class LocalLLM:
135
- """STRETCH stub for a GPU-local Qwen-32B (transformers/vLLM/llama.cpp).
 
 
136
 
137
- Point EndpointLLM at a local OpenAI-compatible server
138
- (LLM_BASE_URL=http://127.0.0.1:8080/v1) and that path already works today; a
139
- truly in-process backend would slot in here. Unimplemented so v1 doesn't
140
- block on GPU access (SPEC §6, §12, §14).
141
  """
142
 
 
 
 
 
 
143
  def complete(self, system: str, user: str, json_mode: bool = False,
144
  timeout: float | None = None) -> str:
145
- raise NotImplementedError(
146
- "local backend needs a GPU; use LLM_BACKEND=endpoint (default) for dev."
147
- )
 
 
 
 
 
148
 
149
 
150
  def get_llm() -> LLM:
 
132
 
133
 
134
  class LocalLLM:
135
+ """In-process Nemotron GGUF via llama.cpp on the Space's GPU — local-first,
136
+ llama.cpp badge, and the Nemotron prize all at once. Generation MUST run inside
137
+ an ``@spaces.GPU`` context (the model loads on first use); see gpu_models.
138
 
139
+ json_mode is honoured by nudging the prompt to emit only JSON — designer/
140
+ loremaster already extract+repair JSON, and the GGUF's reasoning trace is
141
+ suppressed (gpu_models.llama_complete) so the output starts at the JSON.
 
142
  """
143
 
144
+ def __init__(self, max_tokens: int = 14000, temperature: float = 0.7):
145
+ self.model = os.environ.get("LOCAL_LLM_FILE", "Nemotron-3-Nano-30B-A3B (llama.cpp)")
146
+ self.max_tokens = int(os.environ.get("QWEN_MAX_TOKENS", max_tokens))
147
+ self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature))
148
+
149
  def complete(self, system: str, user: str, json_mode: bool = False,
150
  timeout: float | None = None) -> str:
151
+ from .gpu_models import llama_chat
152
+ if json_mode:
153
+ # mapping: lower temp → fewer duplicate concepts (fewer retries);
154
+ # cap tokens → no runaway generation (22-card JSON fits in ~7k).
155
+ return llama_chat(system, user, json_mode=True, max_tokens=10000,
156
+ temperature=0.4)
157
+ return llama_chat(system, user, json_mode=False,
158
+ max_tokens=self.max_tokens, temperature=self.temperature)
159
 
160
 
161
  def get_llm() -> LLM:
frontend/deckview.html CHANGED
@@ -83,8 +83,7 @@
83
  (function () {
84
  function parseDeck() {
85
  try {
86
- const u = new URL(window.location.href);
87
- const d = u.searchParams.get('d');
88
  if (!d) return {back:'', cards:[]};
89
  return JSON.parse(decodeURIComponent(escape(atob(d))));
90
  } catch (e) { return {back:'', cards:[]}; }
 
83
  (function () {
84
  function parseDeck() {
85
  try {
86
+ const d = window.DECK_B64 || new URL(window.location.href).searchParams.get('d');
 
87
  if (!d) return {back:'', cards:[]};
88
  return JSON.parse(decodeURIComponent(escape(atob(d))));
89
  } catch (e) { return {back:'', cards:[]}; }
requirements.txt CHANGED
@@ -1,6 +1,22 @@
1
- gradio==6.14.0
 
 
2
  openai>=1.40,<3
3
  fastapi>=0.110
4
  uvicorn>=0.30
5
  pillow>=10
6
  requests>=2.31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gradio is provided by the Space SDK (do NOT pin it — causes a resolver conflict).
2
+ # torch is preinstalled on ZeroGPU (cu130) — do NOT add it.
3
+ spaces
4
  openai>=1.40,<3
5
  fastapi>=0.110
6
  uvicorn>=0.30
7
  pillow>=10
8
  requests>=2.31
9
+ huggingface_hub
10
+ hf_transfer
11
+
12
+ # LLM — Nemotron GGUF via llama.cpp on the GPU. Install the cu130 prebuilt wheel
13
+ # by DIRECT URL (an --extra-index-url here makes pip backtrack on every other
14
+ # package and the build hangs); the direct ref keeps resolution clean.
15
+ llama_cpp_python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.28-cu130/llama_cpp_python-0.3.28-py3-none-linux_x86_64.whl
16
+
17
+ # Image — FLUX.2 [klein] via diffusers (Flux2KleinPipeline needs diffusers-from-git)
18
+ git+https://github.com/huggingface/diffusers.git
19
+ transformers
20
+ accelerate
21
+ sentencepiece
22
+ protobuf