build-small-hackathon/Cosmere_Codex
4
1"""Cosmere Codex — Voices of the Shards.2 3A Gradio app where each planetary system floats in space; clicking one opens a4live, in-character chat with the Shard (or wanderer) tied to it. Every voice is5performed by a small language model (<= 32B params) kept in voice via its6system prompt. Systems are drawn as animated CSS orreries — a stylized7interpretation, not a faithful star chart.8 9Built for the Hugging Face "Build Small Hackathon" (creative track). Lore facts10adapted from the Coppermind wiki (CC BY-NC-SA). Unofficial fan project, not11affiliated with or endorsed by Brandon Sanderson or Dragonsteel.12"""13 14import math15import re16from threading import Thread17 18import gradio as gr19import spaces20import torch21from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer22 23# Compatibility shim: MiniCPM4.1-8B's bundled modeling code does24# `from transformers.utils.import_utils import is_torch_fx_available`, which newer25# transformers removed from that module. We can't pin transformers down (Gradio26# 6.18 needs huggingface-hub>=1.0, which the old transformers line conflicts27# with), so we restore the symbol before any remote code runs.28import transformers.utils.import_utils as _iu # noqa: E40229if not hasattr(_iu, "is_torch_fx_available"):30 try:31 from transformers.utils import is_torch_fx_available as _fx # type: ignore32 except Exception:33 try:34 import torch.fx # noqa: F40135 def _fx(): # torch.fx imports fine -> treat as available36 return True37 except Exception:38 def _fx():39 return False40 _iu.is_torch_fx_available = _fx41 42from shards import SHARD_ORDER, SHARDS43 44# ---------------------------------------------------------------------------- #45# Model — keep the id in one easy-to-swap constant.46# Active: Qwen/Qwen3-8B (natively supported by transformers 5.x; no47# remote code; works with this Gradio 6.18 env)48# Step up: Qwen/Qwen3-14B (if replies feel thin and compute allows)49# Note: openbmb/MiniCPM4.1-8B (the OpenBMB sponsor-prize model) is NOT usable50# here — its bundled modeling code targets transformers 4.56, but Gradio 6.1851# forces huggingface-hub>=1.0 and therefore transformers 5.x, which refactored52# the internals that 4.56-era remote code depends on. Running it would require53# downgrading Gradio. The is_torch_fx_available shim above is kept harmlessly in54# case a future, 5.x-compatible MiniCPM revision is published.55# ---------------------------------------------------------------------------- #56MODEL_ID = "Qwen/Qwen3-8B"57 58MODEL = None59TOKENIZER = None60 61# Non-thinking sampling settings (snappy, in-character replies).62GEN_KWARGS = dict(63 max_new_tokens=512,64 do_sample=True,65 temperature=0.7,66 top_p=0.8,67 top_k=20,68 repetition_penalty=1.05,69)70 71_THINK_RE = re.compile(r"<think>.*?</think>", flags=re.DOTALL)72 73 74def _load_model():75 """Lazily load the model/tokenizer. Called inside the GPU context so the76 weights land on the ZeroGPU device on first use."""77 global MODEL, TOKENIZER78 if MODEL is None:79 TOKENIZER = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)80 MODEL = AutoModelForCausalLM.from_pretrained(81 MODEL_ID,82 torch_dtype=torch.bfloat16,83 trust_remote_code=True,84 ).to("cuda")85 MODEL.eval()86 return MODEL, TOKENIZER87 88 89def _clean(text: str) -> str:90 """Strip any stray reasoning block (we run in non-thinking mode anyway)."""91 return _THINK_RE.sub("", text).replace("<think>", "").replace("</think>", "").strip()92 93 94@spaces.GPU(duration=120)95def stream_reply(system_prompt, history):96 """Yield the voice's reply token-by-token for a snappy, live feel."""97 model, tokenizer = _load_model()98 99 messages = [{"role": "system", "content": system_prompt}] + history100 text = tokenizer.apply_chat_template(101 messages,102 tokenize=False,103 add_generation_prompt=True,104 enable_thinking=False, # NON-THINKING mode: reply in-character, no reasoning pause105 )106 inputs = tokenizer([text], return_tensors="pt").to(model.device)107 108 streamer = TextIteratorStreamer(109 tokenizer, skip_prompt=True, skip_special_tokens=True110 )111 thread = Thread(112 target=model.generate,113 kwargs=dict(**inputs, streamer=streamer, **GEN_KWARGS),114 )115 thread.start()116 117 partial = ""118 for token in streamer:119 partial += token120 yield _clean(partial)121 122 123# ---------------------------------------------------------------------------- #124# Chat logic125# ---------------------------------------------------------------------------- #126def respond(user_msg, history, shard_id):127 """Append the user's message, then stream the voice's reply into history."""128 history = history or []129 shard = SHARDS.get(shard_id)130 131 if not shard or shard.get("silent") or not (user_msg or "").strip():132 yield history, ""133 return134 135 history = history + [{"role": "user", "content": user_msg}]136 history = history + [{"role": "assistant", "content": ""}]137 yield history, ""138 139 for partial in stream_reply(shard["system_prompt"], history[:-1]):140 history[-1]["content"] = partial141 yield history, ""142 143 144# ---------------------------------------------------------------------------- #145# Orrery rendering — animated, interpretive solar systems (CSS only).146# ---------------------------------------------------------------------------- #147def build_orrery(shard, mode="full"):148 """Render a shard's system as an animated orrery. mode: 'full' or 'emblem'."""149 size = 430 if mode == "full" else 150150 sun_w = 26 if mode == "full" else 15151 rings = []152 for p in shard["orbits"]:153 d = int(p["r"] * size)154 ps = p["s"] if mode == "full" else max(4, round(p["s"] * 0.55))155 label = f'<span class="o-label">{p["name"]}</span>' if mode == "full" else ""156 rings.append(157 f'<div class="o-ring" style="--d:{d}px;--t:{p["t"]}s">'158 f'<span class="o-node"><span class="o-node-in">'159 f'<span class="o-planet" style="--pc:{p["c"]};--ps:{ps}px"></span>'160 f'{label}</span></span></div>'161 )162 cls = "mode-full" if mode == "full" else "mode-emblem"163 return (164 f'<div class="orrery {cls}" style="--sc:{shard["sun_color"]};--sw:{sun_w}px;'165 f'width:{size}px;height:{size}px;">'166 f'<span class="o-sun"></span>{"".join(rings)}</div>'167 )168 169 170# ---------------------------------------------------------------------------- #171# Front-page system symbols — hand-built vector glyphs, one per system.172# Each <svg> is borderless and uses currentColor (the system's theme color, set173# via .sys-symbol{color:var(--c)}) so it tints + glows to match the voice.174# ---------------------------------------------------------------------------- #175def _ring(r, sw=1.0, op=0.7, color="currentColor"):176 return (f'<circle cx="50" cy="50" r="{r}" fill="none" '177 f'stroke="{color}" stroke-width="{sw}" opacity="{op}"/>')178 179 180def _around(r, n, fn, phase=0.0):181 """Place n items evenly around a circle of radius r; fn(x, y, i) -> svg."""182 out = []183 for i in range(n):184 a = phase + i * (2 * math.pi / n)185 out.append(fn(50 + r * math.cos(a), 50 + r * math.sin(a), i))186 return "".join(out)187 188 189def _rays(r0, r1, n, sw=1.4, color="currentColor", op=0.9):190 def line(_x, _y, i):191 a = i * (2 * math.pi / n)192 x0, y0 = 50 + r0 * math.cos(a), 50 + r0 * math.sin(a)193 x1, y1 = 50 + r1 * math.cos(a), 50 + r1 * math.sin(a)194 return (f'<line x1="{x0:.1f}" y1="{y0:.1f}" x2="{x1:.1f}" y2="{y1:.1f}" '195 f'stroke="{color}" stroke-width="{sw}" opacity="{op}"/>')196 return _around(r0, n, line)197 198 199def _dots(r, n, rad, color="currentColor", op=0.95, phase=0.0):200 return _around(201 r, n,202 lambda x, y, i: f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{rad}" fill="{color}" opacity="{op}"/>',203 phase,204 )205 206 207def _svg(body):208 return (f'<svg class="sym-art" viewBox="0 0 100 100" '209 f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')210 211 212def _spin(body, sp, rev=False):213 """Wrap SVG in a group that rotates about the symbol center (view-box origin)."""214 cls = "spin-vb spin-rev" if rev else "spin-vb"215 return f'<g class="{cls}" style="--sp:{sp}s">{body}</g>'216 217 218def _pulse(body, pp=4.5):219 return f'<g class="pulse" style="--pp:{pp}s">{body}</g>'220 221 222def build_symbol(sid):223 """Return an inline SVG glyph for a system — a little moving solar system:224 rings hold, planet-dots/rays/gears orbit at varied speeds, suns pulse."""225 c2 = SHARDS[sid]["accent"]226 sun = '<circle cx="50" cy="50" r="6.5" fill="#fff7e0"/>'227 228 if sid == "drominad": # Hoid — concentric rings, orbiting motes, pulsing sun229 body = (230 "".join(_ring(r, 1, 0.55) for r in (45, 38, 31, 24, 17))231 + _pulse(sun + _ring(7.5, 1.4, 0.9), 5)232 + _spin(_dots(45, 1, 2.2), 40)233 + _spin(_dots(31, 1, 2.0, phase=2.1), 28, rev=True)234 + _spin(_dots(24, 1, 1.8, phase=1.0), 34)235 )236 return _svg(body)237 238 if sid == "harmony": # Scadrian — turning clockwork gear + radiant pulsing sun239 gear = ('<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" '240 'stroke-width="6" stroke-dasharray="4.2 4.2" opacity="0.85"/>')241 body = (242 _spin(gear, 46)243 + _ring(31, 1, 0.5)244 + _spin(_rays(13, 23, 16, 1.2, "#fde6b0", 0.85), 22, rev=True)245 + _pulse('<circle cx="50" cy="50" r="11" fill="#f6e2a0"/>' + _ring(11, 1.5, 0.95), 4.5)246 )247 return _svg(body)248 249 if sid == "devotion_dominion": # Selish — sigil ring of orbiting nodes + dual core250 dual = (251 '<circle cx="44" cy="50" r="10" fill="none" stroke="currentColor" stroke-width="1.6" opacity="0.95"/>'252 f'<circle cx="56" cy="50" r="10" fill="none" stroke="{c2}" stroke-width="1.6" opacity="0.95"/>'253 '<circle cx="50" cy="50" r="2.4" fill="#eaf3ff"/>'254 )255 body = (256 _ring(43, 1, 0.7) + _ring(35, 1, 0.45)257 + _spin(_dots(39, 8, 2.0, op=0.85), 52)258 + _pulse(dual, 5.5)259 )260 return _svg(body)261 262 if sid == "autonomy": # Taldain — slowly spinning figure-8 binary loops263 big = (264 '<circle cx="50" cy="34" r="19" fill="none" stroke="currentColor" stroke-width="2" opacity="0.9"/>'265 '<circle cx="50" cy="66" r="19" fill="none" stroke="currentColor" stroke-width="2" opacity="0.9"/>'266 )267 inner = (268 '<circle cx="50" cy="34" r="13" fill="none" stroke="currentColor" stroke-width="1" opacity="0.5"/>'269 '<circle cx="50" cy="66" r="13" fill="none" stroke="currentColor" stroke-width="1" opacity="0.5"/>'270 '<circle cx="50" cy="34" r="3" fill="#fff"/>'271 '<circle cx="50" cy="66" r="3" fill="#cfe0ff"/>'272 )273 body = _spin(big, 55) + _spin(inner, 34, rev=True)274 return _svg(body)275 276 if sid == "endowment": # Nalthian — iridescent rings, each planet on its own orbit277 cols = ["#e85aa0", "#f2c14e", "#5ad1c8", "#7aa0ff"]278 radii = [44, 35, 26, 17]279 speeds = [40, 30, 22, 15]280 body = "".join(_ring(r, 1.4, 0.85, col) for r, col in zip(radii, cols))281 body += "".join(282 _spin(_dots(r, 1, 2.4, col, phase=i * 1.3), sp, rev=(i % 2 == 1))283 for i, (r, col, sp) in enumerate(zip(radii, cols, speeds))284 )285 body += _pulse('<circle cx="50" cy="50" r="7" fill="#fff6ef"/>', 4)286 return _svg(body)287 288 if sid in ("odium", "cultivation"): # Rosharan — turning rune ring + glowing core289 body = (290 _ring(44, 1, 0.7) + _ring(36, 1, 0.55)291 + _spin(_rays(36, 44, 28, 1.0, "currentColor", 0.6), 64) # rune ticks292 + _ring(21, 1, 0.5)293 + _spin(_dots(28, 3, 1.6, "currentColor", op=0.7), 30, rev=True)294 + _pulse('<circle cx="50" cy="50" r="8" fill="currentColor" opacity="0.9"/>'295 '<circle cx="50" cy="50" r="3.4" fill="#fff" opacity="0.85"/>', 4.2)296 )297 return _svg(body)298 299 return _svg(_ring(40) + sun) # fallback300 301 302def build_core():303 """The central star the systems orbit (a nod to Adonalsium). Non-clickable."""304 return _svg(305 _spin(_rays(20, 33, 24, 1.0, "#ffe7a8", 0.55), 90)306 + '<circle cx="50" cy="50" r="30" fill="#f7d98a" opacity="0.10"/>'307 + _pulse('<circle cx="50" cy="50" r="13" fill="#fff2c8"/>'308 '<circle cx="50" cy="50" r="13" fill="none" stroke="#ffe7a8" stroke-width="1.5"/>', 5)309 )310 311 312def build_cosmos_scene():313 """The front page: systems arranged in a circle, slowly orbiting a central star,314 each one its own little moving solar system."""315 n = len(SHARD_ORDER)316 slots = []317 for i, sid in enumerate(SHARD_ORDER):318 s = SHARDS[sid]319 angle = -90 + i * 360.0 / n # start at top, even spacing320 sz = 132321 dur = 7 + (i % 4) * 1.3 # gentle, varied bob322 delay = (i * 0.7) % 4323 slots.append(324 f'<div class="slot" style="--a:{angle:.2f}deg">'325 f'<div class="deslot"><div class="counter">'326 f'<div class="sys-symbol" data-shard="{sid}" '327 f'style="--c:{s["color"]};--c2:{s["accent"]};--sz:{sz}px;'328 f'--dur:{dur}s;--delay:-{delay}s;">'329 f'<div class="sym-float">{build_symbol(sid)}'330 f'<div class="sys-label"><div class="sys-name">{s["system"]}</div>'331 f'<div class="sys-shard">{s["name"]}</div></div></div>'332 f'</div></div></div></div>'333 )334 return (335 f'<div id="cosmos"><div class="ring">{"".join(slots)}</div>'336 f'<div class="core-star">{build_core()}</div></div>'337 )338 339 340def build_system_panel(shard):341 """The orrery shown beside the chat in a system view."""342 return f'<div id="orrery-wrap">{build_orrery(shard, "full")}</div>'343 344 345# ---------------------------------------------------------------------------- #346# Navigation347# ---------------------------------------------------------------------------- #348def _build_skin(shard):349 """Per-voice theme: a colored banner plus a <style> block that retints the350 chat panel by setting --accent CSS variables on #chat-view."""351 return f"""352<style>353/* !important so this wins over the default in the css= block, which Gradio354 injects later in the DOM (equal specificity would otherwise lose). */355#chat-view {{ --accent: {shard['color']} !important; --accent2: {shard['accent']} !important; }}356</style>357<div class="shard-banner" style="--c:{shard['color']};--c2:{shard['accent']};">358 <div class="shard-name">{shard['name']}</div>359 <div class="shard-sub">{shard['system']} · {shard['shard']}</div>360 <div class="shard-tag">“{shard['tagline']}”</div>361 <div class="shard-planets">Worlds: {shard['planets']}</div>362</div>363"""364 365 366def enter_shard(shard_id):367 """Switch from the map to the conversation, seeded with the voice's greeting."""368 shard = SHARDS[shard_id]369 greeting = [{"role": "assistant", "content": shard["greeting"]}]370 interactive = not shard.get("silent")371 placeholder = (372 "Speak to the voice…" if interactive373 else "No voice answers from this system…"374 )375 return (376 gr.update(visible=False), # map_view377 gr.update(visible=True), # chat_view378 shard_id, # active_shard379 greeting, # chatbot380 _build_skin(shard), # shard_skin381 build_system_panel(shard), # system_orrery382 gr.update(interactive=interactive, placeholder=placeholder, value=""), # user_box383 gr.update(interactive=interactive), # send_btn384 )385 386 387def on_nav(value):388 """Handle a click from the floating map, relayed via the hidden JS proxy box.389 390 The value is "<shard_id>|<nonce>"; the nonce guarantees a fresh change event391 even when the same system is chosen twice in a row. Gradio also emits an392 empty value on initial page load — we treat that (and any unknown id) as a393 no-op so the map is not auto-navigated."""394 sid = (value or "").split("|", 1)[0]395 if sid not in SHARDS:396 return (gr.update(), gr.update(), "", gr.update(),397 gr.update(), gr.update(), gr.update(), gr.update())398 return enter_shard(sid)399 400 401def travel_home():402 """Clear the active voice and return to the star map."""403 return (404 gr.update(visible=True), # map_view405 gr.update(visible=False), # chat_view406 "", # active_shard407 [], # chatbot408 )409 410 411# ---------------------------------------------------------------------------- #412# Styling — dark, cosmic, starry.413# ---------------------------------------------------------------------------- #414CSS = """415.gradio-container, body {416 background:417 radial-gradient(1200px 700px at 18% -8%, #1a1140 0%, transparent 55%),418 radial-gradient(1100px 700px at 88% 6%, #3a103a 0%, transparent 55%),419 radial-gradient(900px 900px at 50% 120%, #07142e 0%, transparent 60%),420 #05060f !important;421 color: #e8e6f0 !important;422}423.gradio-container { max-width: 100% !important; }424/* force Gradio's component palette dark so panels/inputs/buttons match the theme */425.gradio-container, .gradio-container .dark {426 --background-fill-primary: #0b0a18;427 --background-fill-secondary: #100e22;428 --block-background-fill: #0d0b1ccc;429 --block-border-color: #ffffff1a;430 --border-color-primary: #ffffff1a;431 --border-color-accent: #ffffff26;432 --body-text-color: #e8e6f0;433 --body-text-color-subdued: #9c97bd;434 --input-background-fill: #14122a;435 --input-border-color: #ffffff26;436 --button-secondary-background-fill: #181433;437 --button-secondary-background-fill-hover: #221c44;438 --button-secondary-text-color: #d9d4ec;439 --color-accent-soft: #221c44;440 --block-label-text-color: #b7b1d4;441}442/* drifting starfield (two layers, different speeds) */443.gradio-container::before, .gradio-container::after {444 content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;445}446.gradio-container::before {447 background-image:448 radial-gradient(1.4px 1.4px at 20px 30px, #ffffffcc, transparent),449 radial-gradient(1.2px 1.2px at 130px 80px, #cfe8ffaa, transparent),450 radial-gradient(1.6px 1.6px at 220px 160px, #ffffffbb, transparent),451 radial-gradient(1.1px 1.1px at 320px 60px, #ffd9a8aa, transparent),452 radial-gradient(1.3px 1.3px at 90px 220px, #ffffff99, transparent),453 radial-gradient(1.5px 1.5px at 400px 240px, #cfe8ff99, transparent);454 background-size: 480px 320px; opacity: .55; animation: drift 160s linear infinite;455}456.gradio-container::after {457 background-image:458 radial-gradient(1px 1px at 60px 120px, #ffffff88, transparent),459 radial-gradient(1px 1px at 280px 40px, #cfe8ff77, transparent),460 radial-gradient(1px 1px at 180px 260px, #ffffff66, transparent);461 background-size: 320px 300px; opacity: .4; animation: drift 90s linear infinite reverse;462}463@keyframes drift { from { background-position: 0 0; } to { background-position: 480px 320px; } }464.gradio-container > * { position: relative; z-index: 1; }465 466/* header */467#codex-header { text-align:center; padding: 28px 12px 4px; }468#codex-header .title {469 font-family: 'Georgia','Times New Roman',serif; font-size: 2.6rem; letter-spacing: .16em;470 color:#f3e7c4; text-shadow:0 0 18px #d9b15a55, 0 0 42px #6a2bb044; margin:0;471}472#codex-header .sub {473 font-family:'Georgia',serif; font-style:italic; font-size:1.15rem;474 color:#bdb6d8; letter-spacing:.2em; margin:6px 0 2px;475}476#codex-header .intro { color:#9c97bd; font-size:.95rem; max-width:660px; margin:8px auto 0; }477#map-hint { text-align:center; color:#8d88ad; font-size:.9rem; margin:4px 0 6px; letter-spacing:.04em; }478 479/* hidden proxy that the JS click-bridge writes into to trigger navigation */480#nav-proxy { position:absolute !important; left:-9999px !important; top:0 !important;481 width:1px !important; height:1px !important; overflow:hidden !important;482 opacity:0 !important; pointer-events:none !important; }483 484/* ---- systems in a circle, orbiting a central star (a "moving solar system") ---- */485#cosmos { position:relative; height:clamp(470px, 50vw, 580px); margin:0 auto; }486 487/* the ring slowly revolves; .counter spins back so each symbol stays upright */488.ring { position:absolute; inset:0; animation:ring-spin var(--T,150s) linear infinite; }489.slot {490 position:absolute; left:50%; top:50%; width:0; height:0;491 transform:rotate(var(--a)) translate(var(--R,205px));492}493.deslot { position:absolute; left:0; top:0; width:0; height:0;494 transform:rotate(calc(-1 * var(--a))); }495.counter { position:absolute; left:0; top:0; width:0; height:0;496 animation:ring-spin var(--T,150s) linear infinite reverse; }497/* hovering the map gently pauses the orbit so a symbol is easy to read/click */498#cosmos:hover .ring, #cosmos:hover .counter { animation-play-state:paused; }499@keyframes ring-spin { to { transform:rotate(360deg); } }500 501.sys-symbol {502 position:absolute; left:0; top:0; width:var(--sz);503 transform:translate(-50%,-50%); cursor:pointer; color:var(--c); text-align:center;504}505.sym-float {506 animation:floaty var(--dur,8s) ease-in-out infinite; animation-delay:var(--delay,0s);507}508.sym-art {509 display:block; width:100%; height:auto; overflow:visible;510 filter:drop-shadow(0 0 5px color-mix(in srgb, var(--c) 45%, transparent));511 transition:transform .35s ease, filter .35s ease;512}513.sys-symbol:hover { z-index:5; }514.sys-symbol:hover .sym-art { transform:scale(1.12); filter:drop-shadow(0 0 22px var(--c)); }515.sys-label { margin-top:2px; pointer-events:none; transition:opacity .35s ease; }516.sys-name { font:600 .78rem/1.25 'Georgia',serif; color:#d7d2ea; letter-spacing:.04em; }517.sys-shard { font:italic 1.02rem/1.25 'Georgia',serif; color:var(--c); text-shadow:0 0 12px var(--c); }518.sys-symbol:hover .sys-name { color:#f3eeff; }519@keyframes floaty { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-9px); } }520 521/* central star */522.core-star {523 position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);524 width:128px; height:128px; pointer-events:none; color:#ffe7a8;525}526 527/* inner symbol motion: groups that orbit / pulse about the symbol center */528.spin-vb { transform-box:view-box; transform-origin:50px 50px;529 animation:svg-spin var(--sp,30s) linear infinite; }530.spin-vb.spin-rev { animation-direction:reverse; }531.pulse { transform-box:fill-box; transform-origin:center;532 animation:sun-pulse var(--pp,4.5s) ease-in-out infinite; }533@keyframes svg-spin { to { transform:rotate(360deg); } }534@keyframes sun-pulse { 0%,100% { opacity:.9; transform:scale(1); }535 50% { opacity:1; transform:scale(1.14); } }536 537/* mobile: unwind the circle into a clean, tappable 2-up grid */538@media (max-width:760px) {539 #cosmos { height:auto; }540 .ring {541 position:static; inset:auto; transform:none !important; animation:none !important;542 display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start;543 gap:16px 4%; padding:6px 0 12px;544 }545 .slot, .deslot, .counter {546 position:static; width:auto; height:auto;547 transform:none !important; animation:none !important; display:contents;548 }549 .sys-symbol {550 position:static !important; transform:none !important;551 width:43% !important; max-width:200px;552 }553 .core-star { display:none; }554}555 556/* ---- orrery ---- */557.orrery { position:relative; margin:0 auto; }558.o-sun {559 position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);560 width:var(--sw,24px); height:var(--sw,24px); border-radius:50%;561 background:radial-gradient(circle at 38% 33%, #ffffff, var(--sc,#f0d27a) 62%, transparent 100%);562 box-shadow:0 0 16px var(--sc,#f0d27a), 0 0 40px var(--sc,#f0d27a);563}564.o-ring {565 position:absolute; left:50%; top:50%; width:var(--d); height:var(--d);566 margin-left:calc(var(--d) / -2); margin-top:calc(var(--d) / -2);567 border:1px solid rgba(255,255,255,0.12); border-radius:50%;568 animation:o-spin var(--t) linear infinite;569}570.o-node { position:absolute; left:50%; top:0; transform:translate(-50%,-50%); }571.o-node-in {572 display:flex; flex-direction:column; align-items:center; gap:3px;573 animation:o-spin-rev var(--t) linear infinite;574}575.o-planet {576 width:var(--ps); height:var(--ps); border-radius:50%;577 background:radial-gradient(circle at 38% 32%, #ffffffe6, var(--pc) 64%);578 box-shadow:0 0 8px var(--pc), 0 0 2px #000;579}580.o-label {581 font:11px/1 'Georgia',serif; color:#ddd7ef; white-space:nowrap;582 text-shadow:0 0 6px #000, 0 0 3px #000;583}584@keyframes o-spin { to { transform:rotate(360deg); } }585@keyframes o-spin-rev { to { transform:rotate(-360deg); } }586 587/* ---- conversation view ---- */588#chat-view { --accent:#d9b15a; --accent2:#8a8f99; }589.shard-banner {590 text-align:center; padding:16px; border-radius:14px; margin-bottom:12px;591 background:linear-gradient(180deg, var(--c,#d9b15a)1f, transparent 80%);592 border:1px solid var(--c,#d9b15a)55; box-shadow:0 0 30px var(--c,#d9b15a)22;593}594.shard-banner .shard-name {595 font-family:'Georgia',serif; font-size:1.9rem; letter-spacing:.1em;596 color:var(--c,#d9b15a); text-shadow:0 0 18px var(--c,#d9b15a)66;597}598.shard-banner .shard-sub { color:#c8c2e0; font-size:.92rem; margin-top:2px; }599.shard-banner .shard-tag { font-style:italic; color:#e6dff5; margin-top:8px; font-size:1.05rem; }600.shard-banner .shard-planets { color:#8f8ab0; font-size:.82rem; margin-top:6px; letter-spacing:.05em; }601 602#orrery-wrap { display:flex; align-items:center; justify-content:center;603 min-height:460px; overflow:visible; padding:14px 0; }604#shard-chat {605 border:1px solid color-mix(in srgb, var(--accent) 28%, transparent) !important;606 box-shadow:0 0 26px color-mix(in srgb, var(--accent) 14%, transparent) !important;607 background:rgba(10,9,24,0.55) !important;608 font-family:'Georgia','Times New Roman',serif;609}610/* strip Gradio's per-message + toolbar copy / share / copy-all buttons */611#shard-chat .icon-button-wrapper,612#shard-chat .message-buttons,613#shard-chat button[title*="opy" i],614#shard-chat button[title*="hare" i] { display:none !important; }615 616/* themed chat bubbles */617#shard-chat .message, #shard-chat .bot, #shard-chat .user {618 font-family:'Georgia',serif !important; color:#ece9f7 !important;619 border-radius:14px !important;620}621#shard-chat .bot, #shard-chat .message.bot {622 background:rgba(20,18,42,0.85) !important;623 border:1px solid color-mix(in srgb, var(--accent) 22%, transparent) !important;624}625#shard-chat .user, #shard-chat .message.user {626 background:color-mix(in srgb, var(--accent) 16%, rgba(20,18,42,0.85)) !important;627 border:1px solid color-mix(in srgb, var(--accent) 32%, transparent) !important;628}629 630/* unified "glowing pill" composer (text field + send icon as one bar) */631#composer {632 display:flex !important; align-items:center; gap:6px; flex-wrap:nowrap !important;633 margin-top:10px; padding:5px 6px 5px 18px;634 background:rgba(12,11,26,0.72);635 border:1px solid color-mix(in srgb, var(--accent) 38%, transparent);636 border-radius:999px;637 box-shadow:0 0 18px color-mix(in srgb, var(--accent) 16%, transparent);638 transition:box-shadow .3s ease, border-color .3s ease;639}640#composer:focus-within {641 border-color:var(--accent);642 box-shadow:0 0 28px color-mix(in srgb, var(--accent) 38%, transparent);643}644#composer #user-box { flex:1 1 auto !important; min-width:0 !important; }645#composer #user-box textarea {646 background:transparent !important; border:none !important; box-shadow:none !important;647 color:#f1eef9 !important; font-family:'Georgia',serif; font-size:1rem;648 padding:6px 4px !important; resize:none;649}650#user-box textarea::placeholder { color:#9a95bd !important; font-style:italic; }651#send-btn {652 flex:0 0 auto !important; width:42px !important; min-width:42px !important;653 height:42px !important; padding:0 !important; border-radius:50% !important;654 background:linear-gradient(145deg,var(--accent),var(--accent2)) !important;655 color:#11111a !important; border:none !important;656 font-size:1.05rem; line-height:1; display:flex; align-items:center; justify-content:center;657 box-shadow:0 0 12px color-mix(in srgb, var(--accent) 30%, transparent);658 transition:transform .2s ease, box-shadow .2s ease;659}660#send-btn:hover {661 transform:scale(1.08);662 box-shadow:0 0 18px color-mix(in srgb, var(--accent) 55%, transparent);663}664#travel-btn {665 flex:0 0 auto !important; width:fit-content !important; min-width:0 !important;666 background:#140f24cc !important; border:1px solid var(--accent)66 !important;667 color:var(--accent) !important;668}669#travel-btn:hover { background:#1d1736cc !important; box-shadow:0 0 16px var(--accent)44 !important; }670 671/* footer */672#codex-footer { text-align:center; color:#7d789c; font-size:.78rem; margin:22px auto 8px; max-width:760px; line-height:1.5; }673#codex-footer a { color:#a89ce0; }674"""675 676# Injected into <head> so it runs as a real script: one document-level click677# delegate that turns any element carrying data-shard into a navigation event by678# writing "<id>|<nonce>" into the hidden nav-proxy textbox and firing its input.679HEAD_JS = """680<script>681(function () {682 function go(id) {683 var t = document.querySelector('#nav-proxy textarea') ||684 document.querySelector('#nav-proxy input');685 if (!t) { return; }686 var proto = (t.tagName === 'TEXTAREA')687 ? window.HTMLTextAreaElement.prototype688 : window.HTMLInputElement.prototype;689 var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;690 setter.call(t, id + '|' + Date.now());691 t.dispatchEvent(new Event('input', { bubbles: true }));692 }693 document.addEventListener('click', function (e) {694 var el = e.target.closest ? e.target.closest('[data-shard]') : null;695 if (el) { go(el.getAttribute('data-shard')); }696 });697})();698</script>699"""700 701 702# ---------------------------------------------------------------------------- #703# UI704# ---------------------------------------------------------------------------- #705with gr.Blocks(title="Cosmere Codex") as demo:706 active_shard = gr.State("")707 nav_proxy = gr.Textbox(elem_id="nav-proxy", show_label=False)708 709 gr.HTML(710 """711 <div id="codex-header">712 <h1 class="title">THE COSMERE CODEX</h1>713 <div class="sub">Voices of the Shards</div>714 <p class="intro">The systems of the Cosmere, adrift in the dark. Choose one715 to speak — live and in voice — with the god-like Shard (or the wanderer) bound716 to it. Every word is generated in character. Ask them anything.</p>717 </div>718 """719 )720 721 # ---------------------------- MAP VIEW ---------------------------------- #722 with gr.Column(visible=True) as map_view:723 gr.HTML('<div id="map-hint">✦ Click a floating system to enter ✦</div>')724 gr.HTML(build_cosmos_scene())725 726 # -------------------------- CONVERSATION VIEW --------------------------- #727 with gr.Column(visible=False, elem_id="chat-view") as chat_view:728 with gr.Row():729 travel_btn = gr.Button("✦ Travel to another system", elem_id="travel-btn", scale=1)730 shard_skin = gr.HTML()731 with gr.Row(equal_height=False):732 with gr.Column(scale=2, min_width=300):733 system_orrery = gr.HTML(elem_id="system-orrery")734 with gr.Column(scale=3, min_width=320):735 chatbot = gr.Chatbot(736 elem_id="shard-chat",737 height=460,738 show_label=False,739 buttons=[], # no share / copy / copy-all controls740 )741 with gr.Row(elem_id="composer"):742 user_box = gr.Textbox(743 elem_id="user-box",744 placeholder="Speak to the voice…",745 show_label=False,746 container=False,747 scale=5,748 autofocus=True,749 )750 send_btn = gr.Button("➤", elem_id="send-btn", scale=0, min_width=48)751 752 gr.HTML(753 """754 <div id="codex-footer">755 Lore facts adapted from the <a href="https://coppermind.net" target="_blank">Coppermind wiki</a>756 (CC BY-NC-SA). An unofficial, non-commercial fan project — not affiliated with or757 endorsed by Brandon Sanderson or Dragonsteel. The Cosmere and its worlds are their creations.758 </div>759 """760 )761 762 # ----------------------------- WIRING ----------------------------------- #763 nav_outputs = [764 map_view, chat_view, active_shard, chatbot,765 shard_skin, system_orrery, user_box, send_btn,766 ]767 768 # A click on any floating symbol writes "<id>|<nonce>" into nav_proxy (via the769 # head script's click delegate); .change handles it. on_nav no-ops the empty770 # value Gradio emits at load, so the map is not auto-navigated.771 nav_proxy.change(on_nav, inputs=nav_proxy, outputs=nav_outputs)772 773 travel_btn.click(774 travel_home, inputs=None,775 outputs=[map_view, chat_view, active_shard, chatbot],776 )777 778 send_btn.click(respond, [user_box, chatbot, active_shard], [chatbot, user_box])779 user_box.submit(respond, [user_box, chatbot, active_shard], [chatbot, user_box])780 781 782if __name__ == "__main__":783 demo.queue().launch(css=CSS, theme=gr.themes.Base(), head=HEAD_JS)784 