| """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 |
|
|
| |
| |
| |
| |
| |
| import transformers.utils.import_utils as _iu |
| if not hasattr(_iu, "is_torch_fx_available"): |
| try: |
| from transformers.utils import is_torch_fx_available as _fx |
| except Exception: |
| try: |
| import torch.fx |
| def _fx(): |
| return True |
| except Exception: |
| def _fx(): |
| return False |
| _iu.is_torch_fx_available = _fx |
|
|
| from shards import SHARD_ORDER, SHARDS |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| MODEL_ID = "Qwen/Qwen3-8B" |
|
|
| MODEL = None |
| TOKENIZER = None |
|
|
| |
| 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() |
|
|
|
|
| @spaces.GPU(duration=120) |
| 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, |
| ) |
| 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) |
|
|
|
|
| |
| |
| |
| 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, "" |
|
|
|
|
| |
| |
| |
| 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>' |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| 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": |
| 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": |
| 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": |
| 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": |
| 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": |
| 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"): |
| body = ( |
| _ring(44, 1, 0.7) + _ring(36, 1, 0.55) |
| + _spin(_rays(36, 44, 28, 1.0, "currentColor", 0.6), 64) |
| + _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) |
|
|
|
|
| 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 |
| sz = 132 |
| dur = 7 + (i % 4) * 1.3 |
| 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>' |
|
|
|
|
| |
| |
| |
| 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), |
| gr.update(visible=True), |
| shard_id, |
| greeting, |
| _build_skin(shard), |
| build_system_panel(shard), |
| gr.update(interactive=interactive, placeholder=placeholder, value=""), |
| gr.update(interactive=interactive), |
| ) |
|
|
|
|
| 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), |
| gr.update(visible=False), |
| "", |
| [], |
| ) |
|
|
|
|
| |
| |
| |
| 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; } |
| """ |
|
|
| |
| |
| |
| 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> |
| """ |
|
|
|
|
| |
| |
| |
| 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> |
| """ |
| ) |
|
|
| |
| 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()) |
|
|
| |
| 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=[], |
| ) |
| 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> |
| """ |
| ) |
|
|
| |
| nav_outputs = [ |
| map_view, chat_view, active_shard, chatbot, |
| shard_skin, system_orrery, user_box, send_btn, |
| ] |
|
|
| |
| |
| |
| 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) |
|
|