Spaces:
Running on Zero
Running on Zero
| """Cosmere Codex — Voices of the Shards. | |
| A Gradio app where each planetary system floats in space; clicking one opens a | |
| live, in-character chat with the Shard (or wanderer) tied to it. Every voice is | |
| performed by a small language model (<= 32B params) kept in voice via its | |
| system prompt. Systems are drawn as animated CSS orreries — a stylized | |
| interpretation, not a faithful star chart. | |
| Built for the Hugging Face "Build Small Hackathon" (creative track). Lore facts | |
| adapted from the Coppermind wiki (CC BY-NC-SA). Unofficial fan project, not | |
| affiliated with or endorsed by Brandon Sanderson or Dragonsteel. | |
| """ | |
| import math | |
| import re | |
| from threading import Thread | |
| import gradio as gr | |
| import spaces | |
| import torch | |
| from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer | |
| # Compatibility shim: MiniCPM4.1-8B's bundled modeling code does | |
| # `from transformers.utils.import_utils import is_torch_fx_available`, which newer | |
| # transformers removed from that module. We can't pin transformers down (Gradio | |
| # 6.18 needs huggingface-hub>=1.0, which the old transformers line conflicts | |
| # with), so we restore the symbol before any remote code runs. | |
| import transformers.utils.import_utils as _iu # noqa: E402 | |
| if not hasattr(_iu, "is_torch_fx_available"): | |
| try: | |
| from transformers.utils import is_torch_fx_available as _fx # type: ignore | |
| except Exception: | |
| try: | |
| import torch.fx # noqa: F401 | |
| def _fx(): # torch.fx imports fine -> treat as available | |
| return True | |
| except Exception: | |
| def _fx(): | |
| return False | |
| _iu.is_torch_fx_available = _fx | |
| from shards import SHARD_ORDER, SHARDS | |
| # ---------------------------------------------------------------------------- # | |
| # Model — keep the id in one easy-to-swap constant. | |
| # Active: Qwen/Qwen3-8B (natively supported by transformers 5.x; no | |
| # remote code; works with this Gradio 6.18 env) | |
| # Step up: Qwen/Qwen3-14B (if replies feel thin and compute allows) | |
| # Note: openbmb/MiniCPM4.1-8B (the OpenBMB sponsor-prize model) is NOT usable | |
| # here — its bundled modeling code targets transformers 4.56, but Gradio 6.18 | |
| # forces huggingface-hub>=1.0 and therefore transformers 5.x, which refactored | |
| # the internals that 4.56-era remote code depends on. Running it would require | |
| # downgrading Gradio. The is_torch_fx_available shim above is kept harmlessly in | |
| # case a future, 5.x-compatible MiniCPM revision is published. | |
| # ---------------------------------------------------------------------------- # | |
| MODEL_ID = "Qwen/Qwen3-8B" | |
| MODEL = None | |
| TOKENIZER = None | |
| # Non-thinking sampling settings (snappy, in-character replies). | |
| GEN_KWARGS = dict( | |
| max_new_tokens=512, | |
| do_sample=True, | |
| temperature=0.7, | |
| top_p=0.8, | |
| top_k=20, | |
| repetition_penalty=1.05, | |
| ) | |
| _THINK_RE = re.compile(r"<think>.*?</think>", flags=re.DOTALL) | |
| def _load_model(): | |
| """Lazily load the model/tokenizer. Called inside the GPU context so the | |
| weights land on the ZeroGPU device on first use.""" | |
| global MODEL, TOKENIZER | |
| if MODEL is None: | |
| TOKENIZER = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| MODEL = AutoModelForCausalLM.from_pretrained( | |
| MODEL_ID, | |
| torch_dtype=torch.bfloat16, | |
| trust_remote_code=True, | |
| ).to("cuda") | |
| MODEL.eval() | |
| return MODEL, TOKENIZER | |
| def _clean(text: str) -> str: | |
| """Strip any stray reasoning block (we run in non-thinking mode anyway).""" | |
| return _THINK_RE.sub("", text).replace("<think>", "").replace("</think>", "").strip() | |
| def stream_reply(system_prompt, history): | |
| """Yield the voice's reply token-by-token for a snappy, live feel.""" | |
| model, tokenizer = _load_model() | |
| messages = [{"role": "system", "content": system_prompt}] + history | |
| text = tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| enable_thinking=False, # NON-THINKING mode: reply in-character, no reasoning pause | |
| ) | |
| inputs = tokenizer([text], return_tensors="pt").to(model.device) | |
| streamer = TextIteratorStreamer( | |
| tokenizer, skip_prompt=True, skip_special_tokens=True | |
| ) | |
| thread = Thread( | |
| target=model.generate, | |
| kwargs=dict(**inputs, streamer=streamer, **GEN_KWARGS), | |
| ) | |
| thread.start() | |
| partial = "" | |
| for token in streamer: | |
| partial += token | |
| yield _clean(partial) | |
| # ---------------------------------------------------------------------------- # | |
| # Chat logic | |
| # ---------------------------------------------------------------------------- # | |
| def respond(user_msg, history, shard_id): | |
| """Append the user's message, then stream the voice's reply into history.""" | |
| history = history or [] | |
| shard = SHARDS.get(shard_id) | |
| if not shard or shard.get("silent") or not (user_msg or "").strip(): | |
| yield history, "" | |
| return | |
| history = history + [{"role": "user", "content": user_msg}] | |
| history = history + [{"role": "assistant", "content": ""}] | |
| yield history, "" | |
| for partial in stream_reply(shard["system_prompt"], history[:-1]): | |
| history[-1]["content"] = partial | |
| yield history, "" | |
| # ---------------------------------------------------------------------------- # | |
| # Orrery rendering — animated, interpretive solar systems (CSS only). | |
| # ---------------------------------------------------------------------------- # | |
| def build_orrery(shard, mode="full"): | |
| """Render a shard's system as an animated orrery. mode: 'full' or 'emblem'.""" | |
| size = 430 if mode == "full" else 150 | |
| sun_w = 26 if mode == "full" else 15 | |
| rings = [] | |
| for p in shard["orbits"]: | |
| d = int(p["r"] * size) | |
| ps = p["s"] if mode == "full" else max(4, round(p["s"] * 0.55)) | |
| label = f'<span class="o-label">{p["name"]}</span>' if mode == "full" else "" | |
| rings.append( | |
| f'<div class="o-ring" style="--d:{d}px;--t:{p["t"]}s">' | |
| f'<span class="o-node"><span class="o-node-in">' | |
| f'<span class="o-planet" style="--pc:{p["c"]};--ps:{ps}px"></span>' | |
| f'{label}</span></span></div>' | |
| ) | |
| cls = "mode-full" if mode == "full" else "mode-emblem" | |
| return ( | |
| f'<div class="orrery {cls}" style="--sc:{shard["sun_color"]};--sw:{sun_w}px;' | |
| f'width:{size}px;height:{size}px;">' | |
| f'<span class="o-sun"></span>{"".join(rings)}</div>' | |
| ) | |
| # ---------------------------------------------------------------------------- # | |
| # Front-page system symbols — hand-built vector glyphs, one per system. | |
| # Each <svg> is borderless and uses currentColor (the system's theme color, set | |
| # via .sys-symbol{color:var(--c)}) so it tints + glows to match the voice. | |
| # ---------------------------------------------------------------------------- # | |
| def _ring(r, sw=1.0, op=0.7, color="currentColor"): | |
| return (f'<circle cx="50" cy="50" r="{r}" fill="none" ' | |
| f'stroke="{color}" stroke-width="{sw}" opacity="{op}"/>') | |
| def _around(r, n, fn, phase=0.0): | |
| """Place n items evenly around a circle of radius r; fn(x, y, i) -> svg.""" | |
| out = [] | |
| for i in range(n): | |
| a = phase + i * (2 * math.pi / n) | |
| out.append(fn(50 + r * math.cos(a), 50 + r * math.sin(a), i)) | |
| return "".join(out) | |
| def _rays(r0, r1, n, sw=1.4, color="currentColor", op=0.9): | |
| def line(_x, _y, i): | |
| a = i * (2 * math.pi / n) | |
| x0, y0 = 50 + r0 * math.cos(a), 50 + r0 * math.sin(a) | |
| x1, y1 = 50 + r1 * math.cos(a), 50 + r1 * math.sin(a) | |
| return (f'<line x1="{x0:.1f}" y1="{y0:.1f}" x2="{x1:.1f}" y2="{y1:.1f}" ' | |
| f'stroke="{color}" stroke-width="{sw}" opacity="{op}"/>') | |
| return _around(r0, n, line) | |
| def _dots(r, n, rad, color="currentColor", op=0.95, phase=0.0): | |
| return _around( | |
| r, n, | |
| lambda x, y, i: f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{rad}" fill="{color}" opacity="{op}"/>', | |
| phase, | |
| ) | |
| def _svg(body): | |
| return (f'<svg class="sym-art" viewBox="0 0 100 100" ' | |
| f'xmlns="http://www.w3.org/2000/svg">{body}</svg>') | |
| def _spin(body, sp, rev=False): | |
| """Wrap SVG in a group that rotates about the symbol center (view-box origin).""" | |
| cls = "spin-vb spin-rev" if rev else "spin-vb" | |
| return f'<g class="{cls}" style="--sp:{sp}s">{body}</g>' | |
| def _pulse(body, pp=4.5): | |
| return f'<g class="pulse" style="--pp:{pp}s">{body}</g>' | |
| def build_symbol(sid): | |
| """Return an inline SVG glyph for a system — a little moving solar system: | |
| rings hold, planet-dots/rays/gears orbit at varied speeds, suns pulse.""" | |
| c2 = SHARDS[sid]["accent"] | |
| sun = '<circle cx="50" cy="50" r="6.5" fill="#fff7e0"/>' | |
| if sid == "drominad": # Hoid — concentric rings, orbiting motes, pulsing sun | |
| body = ( | |
| "".join(_ring(r, 1, 0.55) for r in (45, 38, 31, 24, 17)) | |
| + _pulse(sun + _ring(7.5, 1.4, 0.9), 5) | |
| + _spin(_dots(45, 1, 2.2), 40) | |
| + _spin(_dots(31, 1, 2.0, phase=2.1), 28, rev=True) | |
| + _spin(_dots(24, 1, 1.8, phase=1.0), 34) | |
| ) | |
| return _svg(body) | |
| if sid == "harmony": # Scadrian — turning clockwork gear + radiant pulsing sun | |
| gear = ('<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" ' | |
| 'stroke-width="6" stroke-dasharray="4.2 4.2" opacity="0.85"/>') | |
| body = ( | |
| _spin(gear, 46) | |
| + _ring(31, 1, 0.5) | |
| + _spin(_rays(13, 23, 16, 1.2, "#fde6b0", 0.85), 22, rev=True) | |
| + _pulse('<circle cx="50" cy="50" r="11" fill="#f6e2a0"/>' + _ring(11, 1.5, 0.95), 4.5) | |
| ) | |
| return _svg(body) | |
| if sid == "devotion_dominion": # Selish — sigil ring of orbiting nodes + dual core | |
| dual = ( | |
| '<circle cx="44" cy="50" r="10" fill="none" stroke="currentColor" stroke-width="1.6" opacity="0.95"/>' | |
| f'<circle cx="56" cy="50" r="10" fill="none" stroke="{c2}" stroke-width="1.6" opacity="0.95"/>' | |
| '<circle cx="50" cy="50" r="2.4" fill="#eaf3ff"/>' | |
| ) | |
| body = ( | |
| _ring(43, 1, 0.7) + _ring(35, 1, 0.45) | |
| + _spin(_dots(39, 8, 2.0, op=0.85), 52) | |
| + _pulse(dual, 5.5) | |
| ) | |
| return _svg(body) | |
| if sid == "autonomy": # Taldain — slowly spinning figure-8 binary loops | |
| big = ( | |
| '<circle cx="50" cy="34" r="19" fill="none" stroke="currentColor" stroke-width="2" opacity="0.9"/>' | |
| '<circle cx="50" cy="66" r="19" fill="none" stroke="currentColor" stroke-width="2" opacity="0.9"/>' | |
| ) | |
| inner = ( | |
| '<circle cx="50" cy="34" r="13" fill="none" stroke="currentColor" stroke-width="1" opacity="0.5"/>' | |
| '<circle cx="50" cy="66" r="13" fill="none" stroke="currentColor" stroke-width="1" opacity="0.5"/>' | |
| '<circle cx="50" cy="34" r="3" fill="#fff"/>' | |
| '<circle cx="50" cy="66" r="3" fill="#cfe0ff"/>' | |
| ) | |
| body = _spin(big, 55) + _spin(inner, 34, rev=True) | |
| return _svg(body) | |
| if sid == "endowment": # Nalthian — iridescent rings, each planet on its own orbit | |
| cols = ["#e85aa0", "#f2c14e", "#5ad1c8", "#7aa0ff"] | |
| radii = [44, 35, 26, 17] | |
| speeds = [40, 30, 22, 15] | |
| body = "".join(_ring(r, 1.4, 0.85, col) for r, col in zip(radii, cols)) | |
| body += "".join( | |
| _spin(_dots(r, 1, 2.4, col, phase=i * 1.3), sp, rev=(i % 2 == 1)) | |
| for i, (r, col, sp) in enumerate(zip(radii, cols, speeds)) | |
| ) | |
| body += _pulse('<circle cx="50" cy="50" r="7" fill="#fff6ef"/>', 4) | |
| return _svg(body) | |
| if sid in ("odium", "cultivation"): # Rosharan — turning rune ring + glowing core | |
| body = ( | |
| _ring(44, 1, 0.7) + _ring(36, 1, 0.55) | |
| + _spin(_rays(36, 44, 28, 1.0, "currentColor", 0.6), 64) # rune ticks | |
| + _ring(21, 1, 0.5) | |
| + _spin(_dots(28, 3, 1.6, "currentColor", op=0.7), 30, rev=True) | |
| + _pulse('<circle cx="50" cy="50" r="8" fill="currentColor" opacity="0.9"/>' | |
| '<circle cx="50" cy="50" r="3.4" fill="#fff" opacity="0.85"/>', 4.2) | |
| ) | |
| return _svg(body) | |
| return _svg(_ring(40) + sun) # fallback | |
| def build_core(): | |
| """The central star the systems orbit (a nod to Adonalsium). Non-clickable.""" | |
| return _svg( | |
| _spin(_rays(20, 33, 24, 1.0, "#ffe7a8", 0.55), 90) | |
| + '<circle cx="50" cy="50" r="30" fill="#f7d98a" opacity="0.10"/>' | |
| + _pulse('<circle cx="50" cy="50" r="13" fill="#fff2c8"/>' | |
| '<circle cx="50" cy="50" r="13" fill="none" stroke="#ffe7a8" stroke-width="1.5"/>', 5) | |
| ) | |
| def build_cosmos_scene(): | |
| """The front page: systems arranged in a circle, slowly orbiting a central star, | |
| each one its own little moving solar system.""" | |
| n = len(SHARD_ORDER) | |
| slots = [] | |
| for i, sid in enumerate(SHARD_ORDER): | |
| s = SHARDS[sid] | |
| angle = -90 + i * 360.0 / n # start at top, even spacing | |
| sz = 132 | |
| dur = 7 + (i % 4) * 1.3 # gentle, varied bob | |
| delay = (i * 0.7) % 4 | |
| slots.append( | |
| f'<div class="slot" style="--a:{angle:.2f}deg">' | |
| f'<div class="deslot"><div class="counter">' | |
| f'<div class="sys-symbol" data-shard="{sid}" ' | |
| f'style="--c:{s["color"]};--c2:{s["accent"]};--sz:{sz}px;' | |
| f'--dur:{dur}s;--delay:-{delay}s;">' | |
| f'<div class="sym-float">{build_symbol(sid)}' | |
| f'<div class="sys-label"><div class="sys-name">{s["system"]}</div>' | |
| f'<div class="sys-shard">{s["name"]}</div></div></div>' | |
| f'</div></div></div></div>' | |
| ) | |
| return ( | |
| f'<div id="cosmos"><div class="ring">{"".join(slots)}</div>' | |
| f'<div class="core-star">{build_core()}</div></div>' | |
| ) | |
| def build_system_panel(shard): | |
| """The orrery shown beside the chat in a system view.""" | |
| return f'<div id="orrery-wrap">{build_orrery(shard, "full")}</div>' | |
| # ---------------------------------------------------------------------------- # | |
| # Navigation | |
| # ---------------------------------------------------------------------------- # | |
| def _build_skin(shard): | |
| """Per-voice theme: a colored banner plus a <style> block that retints the | |
| chat panel by setting --accent CSS variables on #chat-view.""" | |
| return f""" | |
| <style> | |
| /* !important so this wins over the default in the css= block, which Gradio | |
| injects later in the DOM (equal specificity would otherwise lose). */ | |
| #chat-view {{ --accent: {shard['color']} !important; --accent2: {shard['accent']} !important; }} | |
| </style> | |
| <div class="shard-banner" style="--c:{shard['color']};--c2:{shard['accent']};"> | |
| <div class="shard-name">{shard['name']}</div> | |
| <div class="shard-sub">{shard['system']} · {shard['shard']}</div> | |
| <div class="shard-tag">“{shard['tagline']}”</div> | |
| <div class="shard-planets">Worlds: {shard['planets']}</div> | |
| </div> | |
| """ | |
| def enter_shard(shard_id): | |
| """Switch from the map to the conversation, seeded with the voice's greeting.""" | |
| shard = SHARDS[shard_id] | |
| greeting = [{"role": "assistant", "content": shard["greeting"]}] | |
| interactive = not shard.get("silent") | |
| placeholder = ( | |
| "Speak to the voice…" if interactive | |
| else "No voice answers from this system…" | |
| ) | |
| return ( | |
| gr.update(visible=False), # map_view | |
| gr.update(visible=True), # chat_view | |
| shard_id, # active_shard | |
| greeting, # chatbot | |
| _build_skin(shard), # shard_skin | |
| build_system_panel(shard), # system_orrery | |
| gr.update(interactive=interactive, placeholder=placeholder, value=""), # user_box | |
| gr.update(interactive=interactive), # send_btn | |
| ) | |
| def on_nav(value): | |
| """Handle a click from the floating map, relayed via the hidden JS proxy box. | |
| The value is "<shard_id>|<nonce>"; the nonce guarantees a fresh change event | |
| even when the same system is chosen twice in a row. Gradio also emits an | |
| empty value on initial page load — we treat that (and any unknown id) as a | |
| no-op so the map is not auto-navigated.""" | |
| sid = (value or "").split("|", 1)[0] | |
| if sid not in SHARDS: | |
| return (gr.update(), gr.update(), "", gr.update(), | |
| gr.update(), gr.update(), gr.update(), gr.update()) | |
| return enter_shard(sid) | |
| def travel_home(): | |
| """Clear the active voice and return to the star map.""" | |
| return ( | |
| gr.update(visible=True), # map_view | |
| gr.update(visible=False), # chat_view | |
| "", # active_shard | |
| [], # chatbot | |
| ) | |
| # ---------------------------------------------------------------------------- # | |
| # Styling — dark, cosmic, starry. | |
| # ---------------------------------------------------------------------------- # | |
| CSS = """ | |
| .gradio-container, body { | |
| background: | |
| radial-gradient(1200px 700px at 18% -8%, #1a1140 0%, transparent 55%), | |
| radial-gradient(1100px 700px at 88% 6%, #3a103a 0%, transparent 55%), | |
| radial-gradient(900px 900px at 50% 120%, #07142e 0%, transparent 60%), | |
| #05060f !important; | |
| color: #e8e6f0 !important; | |
| } | |
| .gradio-container { max-width: 100% !important; } | |
| /* force Gradio's component palette dark so panels/inputs/buttons match the theme */ | |
| .gradio-container, .gradio-container .dark { | |
| --background-fill-primary: #0b0a18; | |
| --background-fill-secondary: #100e22; | |
| --block-background-fill: #0d0b1ccc; | |
| --block-border-color: #ffffff1a; | |
| --border-color-primary: #ffffff1a; | |
| --border-color-accent: #ffffff26; | |
| --body-text-color: #e8e6f0; | |
| --body-text-color-subdued: #9c97bd; | |
| --input-background-fill: #14122a; | |
| --input-border-color: #ffffff26; | |
| --button-secondary-background-fill: #181433; | |
| --button-secondary-background-fill-hover: #221c44; | |
| --button-secondary-text-color: #d9d4ec; | |
| --color-accent-soft: #221c44; | |
| --block-label-text-color: #b7b1d4; | |
| } | |
| /* drifting starfield (two layers, different speeds) */ | |
| .gradio-container::before, .gradio-container::after { | |
| content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; | |
| } | |
| .gradio-container::before { | |
| background-image: | |
| radial-gradient(1.4px 1.4px at 20px 30px, #ffffffcc, transparent), | |
| radial-gradient(1.2px 1.2px at 130px 80px, #cfe8ffaa, transparent), | |
| radial-gradient(1.6px 1.6px at 220px 160px, #ffffffbb, transparent), | |
| radial-gradient(1.1px 1.1px at 320px 60px, #ffd9a8aa, transparent), | |
| radial-gradient(1.3px 1.3px at 90px 220px, #ffffff99, transparent), | |
| radial-gradient(1.5px 1.5px at 400px 240px, #cfe8ff99, transparent); | |
| background-size: 480px 320px; opacity: .55; animation: drift 160s linear infinite; | |
| } | |
| .gradio-container::after { | |
| background-image: | |
| radial-gradient(1px 1px at 60px 120px, #ffffff88, transparent), | |
| radial-gradient(1px 1px at 280px 40px, #cfe8ff77, transparent), | |
| radial-gradient(1px 1px at 180px 260px, #ffffff66, transparent); | |
| background-size: 320px 300px; opacity: .4; animation: drift 90s linear infinite reverse; | |
| } | |
| @keyframes drift { from { background-position: 0 0; } to { background-position: 480px 320px; } } | |
| .gradio-container > * { position: relative; z-index: 1; } | |
| /* header */ | |
| #codex-header { text-align:center; padding: 28px 12px 4px; } | |
| #codex-header .title { | |
| font-family: 'Georgia','Times New Roman',serif; font-size: 2.6rem; letter-spacing: .16em; | |
| color:#f3e7c4; text-shadow:0 0 18px #d9b15a55, 0 0 42px #6a2bb044; margin:0; | |
| } | |
| #codex-header .sub { | |
| font-family:'Georgia',serif; font-style:italic; font-size:1.15rem; | |
| color:#bdb6d8; letter-spacing:.2em; margin:6px 0 2px; | |
| } | |
| #codex-header .intro { color:#9c97bd; font-size:.95rem; max-width:660px; margin:8px auto 0; } | |
| #map-hint { text-align:center; color:#8d88ad; font-size:.9rem; margin:4px 0 6px; letter-spacing:.04em; } | |
| /* hidden proxy that the JS click-bridge writes into to trigger navigation */ | |
| #nav-proxy { position:absolute !important; left:-9999px !important; top:0 !important; | |
| width:1px !important; height:1px !important; overflow:hidden !important; | |
| opacity:0 !important; pointer-events:none !important; } | |
| /* ---- systems in a circle, orbiting a central star (a "moving solar system") ---- */ | |
| #cosmos { position:relative; height:clamp(470px, 50vw, 580px); margin:0 auto; } | |
| /* the ring slowly revolves; .counter spins back so each symbol stays upright */ | |
| .ring { position:absolute; inset:0; animation:ring-spin var(--T,150s) linear infinite; } | |
| .slot { | |
| position:absolute; left:50%; top:50%; width:0; height:0; | |
| transform:rotate(var(--a)) translate(var(--R,205px)); | |
| } | |
| .deslot { position:absolute; left:0; top:0; width:0; height:0; | |
| transform:rotate(calc(-1 * var(--a))); } | |
| .counter { position:absolute; left:0; top:0; width:0; height:0; | |
| animation:ring-spin var(--T,150s) linear infinite reverse; } | |
| /* hovering the map gently pauses the orbit so a symbol is easy to read/click */ | |
| #cosmos:hover .ring, #cosmos:hover .counter { animation-play-state:paused; } | |
| @keyframes ring-spin { to { transform:rotate(360deg); } } | |
| .sys-symbol { | |
| position:absolute; left:0; top:0; width:var(--sz); | |
| transform:translate(-50%,-50%); cursor:pointer; color:var(--c); text-align:center; | |
| } | |
| .sym-float { | |
| animation:floaty var(--dur,8s) ease-in-out infinite; animation-delay:var(--delay,0s); | |
| } | |
| .sym-art { | |
| display:block; width:100%; height:auto; overflow:visible; | |
| filter:drop-shadow(0 0 5px color-mix(in srgb, var(--c) 45%, transparent)); | |
| transition:transform .35s ease, filter .35s ease; | |
| } | |
| .sys-symbol:hover { z-index:5; } | |
| .sys-symbol:hover .sym-art { transform:scale(1.12); filter:drop-shadow(0 0 22px var(--c)); } | |
| .sys-label { margin-top:2px; pointer-events:none; transition:opacity .35s ease; } | |
| .sys-name { font:600 .78rem/1.25 'Georgia',serif; color:#d7d2ea; letter-spacing:.04em; } | |
| .sys-shard { font:italic 1.02rem/1.25 'Georgia',serif; color:var(--c); text-shadow:0 0 12px var(--c); } | |
| .sys-symbol:hover .sys-name { color:#f3eeff; } | |
| @keyframes floaty { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-9px); } } | |
| /* central star */ | |
| .core-star { | |
| position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); | |
| width:128px; height:128px; pointer-events:none; color:#ffe7a8; | |
| } | |
| /* inner symbol motion: groups that orbit / pulse about the symbol center */ | |
| .spin-vb { transform-box:view-box; transform-origin:50px 50px; | |
| animation:svg-spin var(--sp,30s) linear infinite; } | |
| .spin-vb.spin-rev { animation-direction:reverse; } | |
| .pulse { transform-box:fill-box; transform-origin:center; | |
| animation:sun-pulse var(--pp,4.5s) ease-in-out infinite; } | |
| @keyframes svg-spin { to { transform:rotate(360deg); } } | |
| @keyframes sun-pulse { 0%,100% { opacity:.9; transform:scale(1); } | |
| 50% { opacity:1; transform:scale(1.14); } } | |
| /* mobile: unwind the circle into a clean, tappable 2-up grid */ | |
| @media (max-width:760px) { | |
| #cosmos { height:auto; } | |
| .ring { | |
| position:static; inset:auto; transform:none !important; animation:none !important; | |
| display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; | |
| gap:16px 4%; padding:6px 0 12px; | |
| } | |
| .slot, .deslot, .counter { | |
| position:static; width:auto; height:auto; | |
| transform:none !important; animation:none !important; display:contents; | |
| } | |
| .sys-symbol { | |
| position:static !important; transform:none !important; | |
| width:43% !important; max-width:200px; | |
| } | |
| .core-star { display:none; } | |
| } | |
| /* ---- orrery ---- */ | |
| .orrery { position:relative; margin:0 auto; } | |
| .o-sun { | |
| position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); | |
| width:var(--sw,24px); height:var(--sw,24px); border-radius:50%; | |
| background:radial-gradient(circle at 38% 33%, #ffffff, var(--sc,#f0d27a) 62%, transparent 100%); | |
| box-shadow:0 0 16px var(--sc,#f0d27a), 0 0 40px var(--sc,#f0d27a); | |
| } | |
| .o-ring { | |
| position:absolute; left:50%; top:50%; width:var(--d); height:var(--d); | |
| margin-left:calc(var(--d) / -2); margin-top:calc(var(--d) / -2); | |
| border:1px solid rgba(255,255,255,0.12); border-radius:50%; | |
| animation:o-spin var(--t) linear infinite; | |
| } | |
| .o-node { position:absolute; left:50%; top:0; transform:translate(-50%,-50%); } | |
| .o-node-in { | |
| display:flex; flex-direction:column; align-items:center; gap:3px; | |
| animation:o-spin-rev var(--t) linear infinite; | |
| } | |
| .o-planet { | |
| width:var(--ps); height:var(--ps); border-radius:50%; | |
| background:radial-gradient(circle at 38% 32%, #ffffffe6, var(--pc) 64%); | |
| box-shadow:0 0 8px var(--pc), 0 0 2px #000; | |
| } | |
| .o-label { | |
| font:11px/1 'Georgia',serif; color:#ddd7ef; white-space:nowrap; | |
| text-shadow:0 0 6px #000, 0 0 3px #000; | |
| } | |
| @keyframes o-spin { to { transform:rotate(360deg); } } | |
| @keyframes o-spin-rev { to { transform:rotate(-360deg); } } | |
| /* ---- conversation view ---- */ | |
| #chat-view { --accent:#d9b15a; --accent2:#8a8f99; } | |
| .shard-banner { | |
| text-align:center; padding:16px; border-radius:14px; margin-bottom:12px; | |
| background:linear-gradient(180deg, var(--c,#d9b15a)1f, transparent 80%); | |
| border:1px solid var(--c,#d9b15a)55; box-shadow:0 0 30px var(--c,#d9b15a)22; | |
| } | |
| .shard-banner .shard-name { | |
| font-family:'Georgia',serif; font-size:1.9rem; letter-spacing:.1em; | |
| color:var(--c,#d9b15a); text-shadow:0 0 18px var(--c,#d9b15a)66; | |
| } | |
| .shard-banner .shard-sub { color:#c8c2e0; font-size:.92rem; margin-top:2px; } | |
| .shard-banner .shard-tag { font-style:italic; color:#e6dff5; margin-top:8px; font-size:1.05rem; } | |
| .shard-banner .shard-planets { color:#8f8ab0; font-size:.82rem; margin-top:6px; letter-spacing:.05em; } | |
| #orrery-wrap { display:flex; align-items:center; justify-content:center; | |
| min-height:460px; overflow:visible; padding:14px 0; } | |
| #shard-chat { | |
| border:1px solid color-mix(in srgb, var(--accent) 28%, transparent) !important; | |
| box-shadow:0 0 26px color-mix(in srgb, var(--accent) 14%, transparent) !important; | |
| background:rgba(10,9,24,0.55) !important; | |
| font-family:'Georgia','Times New Roman',serif; | |
| } | |
| /* strip Gradio's per-message + toolbar copy / share / copy-all buttons */ | |
| #shard-chat .icon-button-wrapper, | |
| #shard-chat .message-buttons, | |
| #shard-chat button[title*="opy" i], | |
| #shard-chat button[title*="hare" i] { display:none !important; } | |
| /* themed chat bubbles */ | |
| #shard-chat .message, #shard-chat .bot, #shard-chat .user { | |
| font-family:'Georgia',serif !important; color:#ece9f7 !important; | |
| border-radius:14px !important; | |
| } | |
| #shard-chat .bot, #shard-chat .message.bot { | |
| background:rgba(20,18,42,0.85) !important; | |
| border:1px solid color-mix(in srgb, var(--accent) 22%, transparent) !important; | |
| } | |
| #shard-chat .user, #shard-chat .message.user { | |
| background:color-mix(in srgb, var(--accent) 16%, rgba(20,18,42,0.85)) !important; | |
| border:1px solid color-mix(in srgb, var(--accent) 32%, transparent) !important; | |
| } | |
| /* unified "glowing pill" composer (text field + send icon as one bar) */ | |
| #composer { | |
| display:flex !important; align-items:center; gap:6px; flex-wrap:nowrap !important; | |
| margin-top:10px; padding:5px 6px 5px 18px; | |
| background:rgba(12,11,26,0.72); | |
| border:1px solid color-mix(in srgb, var(--accent) 38%, transparent); | |
| border-radius:999px; | |
| box-shadow:0 0 18px color-mix(in srgb, var(--accent) 16%, transparent); | |
| transition:box-shadow .3s ease, border-color .3s ease; | |
| } | |
| #composer:focus-within { | |
| border-color:var(--accent); | |
| box-shadow:0 0 28px color-mix(in srgb, var(--accent) 38%, transparent); | |
| } | |
| #composer #user-box { flex:1 1 auto !important; min-width:0 !important; } | |
| #composer #user-box textarea { | |
| background:transparent !important; border:none !important; box-shadow:none !important; | |
| color:#f1eef9 !important; font-family:'Georgia',serif; font-size:1rem; | |
| padding:6px 4px !important; resize:none; | |
| } | |
| #user-box textarea::placeholder { color:#9a95bd !important; font-style:italic; } | |
| #send-btn { | |
| flex:0 0 auto !important; width:42px !important; min-width:42px !important; | |
| height:42px !important; padding:0 !important; border-radius:50% !important; | |
| background:linear-gradient(145deg,var(--accent),var(--accent2)) !important; | |
| color:#11111a !important; border:none !important; | |
| font-size:1.05rem; line-height:1; display:flex; align-items:center; justify-content:center; | |
| box-shadow:0 0 12px color-mix(in srgb, var(--accent) 30%, transparent); | |
| transition:transform .2s ease, box-shadow .2s ease; | |
| } | |
| #send-btn:hover { | |
| transform:scale(1.08); | |
| box-shadow:0 0 18px color-mix(in srgb, var(--accent) 55%, transparent); | |
| } | |
| #travel-btn { | |
| flex:0 0 auto !important; width:fit-content !important; min-width:0 !important; | |
| background:#140f24cc !important; border:1px solid var(--accent)66 !important; | |
| color:var(--accent) !important; | |
| } | |
| #travel-btn:hover { background:#1d1736cc !important; box-shadow:0 0 16px var(--accent)44 !important; } | |
| /* footer */ | |
| #codex-footer { text-align:center; color:#7d789c; font-size:.78rem; margin:22px auto 8px; max-width:760px; line-height:1.5; } | |
| #codex-footer a { color:#a89ce0; } | |
| """ | |
| # Injected into <head> so it runs as a real script: one document-level click | |
| # delegate that turns any element carrying data-shard into a navigation event by | |
| # writing "<id>|<nonce>" into the hidden nav-proxy textbox and firing its input. | |
| HEAD_JS = """ | |
| <script> | |
| (function () { | |
| function go(id) { | |
| var t = document.querySelector('#nav-proxy textarea') || | |
| document.querySelector('#nav-proxy input'); | |
| if (!t) { return; } | |
| var proto = (t.tagName === 'TEXTAREA') | |
| ? window.HTMLTextAreaElement.prototype | |
| : window.HTMLInputElement.prototype; | |
| var setter = Object.getOwnPropertyDescriptor(proto, 'value').set; | |
| setter.call(t, id + '|' + Date.now()); | |
| t.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| document.addEventListener('click', function (e) { | |
| var el = e.target.closest ? e.target.closest('[data-shard]') : null; | |
| if (el) { go(el.getAttribute('data-shard')); } | |
| }); | |
| })(); | |
| </script> | |
| """ | |
| # ---------------------------------------------------------------------------- # | |
| # UI | |
| # ---------------------------------------------------------------------------- # | |
| with gr.Blocks(title="Cosmere Codex") as demo: | |
| active_shard = gr.State("") | |
| nav_proxy = gr.Textbox(elem_id="nav-proxy", show_label=False) | |
| gr.HTML( | |
| """ | |
| <div id="codex-header"> | |
| <h1 class="title">THE COSMERE CODEX</h1> | |
| <div class="sub">Voices of the Shards</div> | |
| <p class="intro">The systems of the Cosmere, adrift in the dark. Choose one | |
| to speak — live and in voice — with the god-like Shard (or the wanderer) bound | |
| to it. Every word is generated in character. Ask them anything.</p> | |
| </div> | |
| """ | |
| ) | |
| # ---------------------------- MAP VIEW ---------------------------------- # | |
| with gr.Column(visible=True) as map_view: | |
| gr.HTML('<div id="map-hint">✦ Click a floating system to enter ✦</div>') | |
| gr.HTML(build_cosmos_scene()) | |
| # -------------------------- CONVERSATION VIEW --------------------------- # | |
| with gr.Column(visible=False, elem_id="chat-view") as chat_view: | |
| with gr.Row(): | |
| travel_btn = gr.Button("✦ Travel to another system", elem_id="travel-btn", scale=1) | |
| shard_skin = gr.HTML() | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=2, min_width=300): | |
| system_orrery = gr.HTML(elem_id="system-orrery") | |
| with gr.Column(scale=3, min_width=320): | |
| chatbot = gr.Chatbot( | |
| elem_id="shard-chat", | |
| height=460, | |
| show_label=False, | |
| buttons=[], # no share / copy / copy-all controls | |
| ) | |
| with gr.Row(elem_id="composer"): | |
| user_box = gr.Textbox( | |
| elem_id="user-box", | |
| placeholder="Speak to the voice…", | |
| show_label=False, | |
| container=False, | |
| scale=5, | |
| autofocus=True, | |
| ) | |
| send_btn = gr.Button("➤", elem_id="send-btn", scale=0, min_width=48) | |
| gr.HTML( | |
| """ | |
| <div id="codex-footer"> | |
| Lore facts adapted from the <a href="https://coppermind.net" target="_blank">Coppermind wiki</a> | |
| (CC BY-NC-SA). An unofficial, non-commercial fan project — not affiliated with or | |
| endorsed by Brandon Sanderson or Dragonsteel. The Cosmere and its worlds are their creations. | |
| </div> | |
| """ | |
| ) | |
| # ----------------------------- WIRING ----------------------------------- # | |
| nav_outputs = [ | |
| map_view, chat_view, active_shard, chatbot, | |
| shard_skin, system_orrery, user_box, send_btn, | |
| ] | |
| # A click on any floating symbol writes "<id>|<nonce>" into nav_proxy (via the | |
| # head script's click delegate); .change handles it. on_nav no-ops the empty | |
| # value Gradio emits at load, so the map is not auto-navigated. | |
| nav_proxy.change(on_nav, inputs=nav_proxy, outputs=nav_outputs) | |
| travel_btn.click( | |
| travel_home, inputs=None, | |
| outputs=[map_view, chat_view, active_shard, chatbot], | |
| ) | |
| send_btn.click(respond, [user_box, chatbot, active_shard], [chatbot, user_box]) | |
| user_box.submit(respond, [user_box, chatbot, active_shard], [chatbot, user_box]) | |
| if __name__ == "__main__": | |
| demo.queue().launch(css=CSS, theme=gr.themes.Base(), head=HEAD_JS) | |