File size: 30,807 Bytes
f8c1b4a ffb4f53 f8c1b4a c2cb46f ffb4f53 f8c1b4a c2cb46f f8c1b4a 652e422 f8c1b4a c2cb46f f8c1b4a ffb4f53 f8c1b4a c2cb46f ffb4f53 fcf8063 c2cb46f ffb4f53 c2cb46f ffb4f53 c2cb46f 65e1839 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 c2cb46f ffb4f53 c2cb46f ffb4f53 c2cb46f 65e1839 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 f8c1b4a c2cb46f f8c1b4a c2cb46f 8629759 fcf8063 f8c1b4a 652e422 f8c1b4a 652e422 f8c1b4a 652e422 f8c1b4a 652e422 f8c1b4a 652e422 f8c1b4a 652e422 f8c1b4a 652e422 f8c1b4a ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 c2cb46f 65e1839 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 fcf8063 ffb4f53 652e422 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a c2cb46f f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a c2cb46f f8c1b4a ffb4f53 f8c1b4a ffb4f53 f8c1b4a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 | """
Reality Divergence + Elsewhere
AI-native platform for exploring counterfactual personal and historical timelines.
Uses Qwen2.5-7B-Instruct (via the Hugging Face Inference API) for reasoning, and
renders results as structured, legible HTML cards.
"""
import base64
import html
import json
import os
from io import BytesIO
from typing import Dict, Optional
import gradio as gr
import config
import prompts
from model_client import infer_text, infer_json
from image_client import get_image_client
# ============================================================================
# Fallback Data (for offline/demo mode)
# ============================================================================
FALLBACK_ANALYSIS = {
"emotion_score": 6,
"regret_score": 7,
"self_blame_score": 3,
"category": "Career",
"risk_level": "low",
"risk_markers": []
}
FALLBACK_MIRROR = (
"You're carrying a moment where one choiceβperhaps a risk declined or acceptedβ"
"changed the shape of what came next. A part of you may still be wondering who you'd have become "
"if that moment had unfolded differently. That curiosity deserves a careful frame."
)
FALLBACK_TIMELINE = {
"title": "The Path Not Taken",
"divergence": "At the moment you made your choice, everything branched.",
"ripples": [
"Your confidence would have grown in a different direction.",
"New relationships might have formed, others would have stayed distant.",
"Your skills would have developed along an alternate path.",
"The people around you would have seen a different version of you.",
"Your understanding of yourself would be fundamentally different."
],
"the_you": (
"You might have become more outwardly bold or more introspective, depending on the path. "
"But you would have carried different lessons, different wounds, different strengths."
),
"return": {
"opening": "In the life you actually lived, you learned something you could not have learned elsewhere.",
"strengths": [
"You learned to make choices under incomplete information.",
"You carried responsibility even when the answer was unclear.",
"You kept enough curiosity to revisit this moment with honesty."
],
"lesson": "A path can be meaningful without being the only path that could have mattered.",
"reflection_question": "What did your actual timeline make possible that the alternate one might have cost?"
},
"quote": "Another life may have opened doors, but this one taught you how to stand."
}
FALLBACK_WORLD_DIVERGENCE = {
"title": "Apple Never Launches the iPhone",
"divergence_point": "2007: Apple decides to focus on computers instead of mobile",
"year_divergence": 2007,
"ripples": [
"Business phones and BlackBerrys dominate mobile computing.",
"Apps arrive later and through different distribution channels.",
"Mobile photography remains less central to daily life.",
"Touchscreen adoption slows without the iPhone's refinement.",
"Tech culture evolves around portable computing differently."
],
"world_outcome": (
"A world where mobile computing exists but remains fragmented. "
"Productivity-focused devices outnumber entertainment devices. "
"The social media landscape never reaches its current scale."
),
"economy": "Business hardware companies thrive. Software consolidation never happens.",
"culture": "Always-connected culture develops slower. Social media remains desktop-first.",
"technology": "Cloud services evolve differently. AR/VR developments diverge.",
"winners": ["Microsoft", "BlackBerry", "Desktop computing"],
"losers": ["App-based startups", "Mobile creators", "Social media explosion"],
"reflection": "One device shaped a culture. What else in our world hinges on a single innovation?"
}
# ============================================================================
# Utility Functions
# ============================================================================
def safe_int(val, default=0, min_val=0, max_val=10):
"""Safely convert and clamp integer values."""
try:
return max(min_val, min(max_val, int(val)))
except (ValueError, TypeError):
return default
def safe_str(val, default=""):
"""Safely convert to string."""
try:
return str(val).strip() if val else default
except Exception:
return default
def safe_list(val, default=None):
"""Safely extract list values."""
try:
if isinstance(val, list):
return [safe_str(v) for v in val]
return default or []
except Exception:
return default or []
def merge_analysis_with_fallback(parsed: Optional[Dict]) -> Dict:
"""Merge parsed analysis with fallback; one-way upgrade (risk only escalates)."""
if not parsed:
return FALLBACK_ANALYSIS
fallback = FALLBACK_ANALYSIS.copy()
# Merge, preferring parsed values where valid
result = {
"emotion_score": safe_int(parsed.get("emotion_score"), fallback["emotion_score"]),
"regret_score": safe_int(parsed.get("regret_score"), fallback["regret_score"]),
"self_blame_score": safe_int(parsed.get("self_blame_score"), fallback["self_blame_score"]),
"category": safe_str(parsed.get("category"), fallback["category"]),
"risk_level": "high" if (parsed.get("risk_level") == "high" or fallback["risk_level"] == "high") else safe_str(parsed.get("risk_level"), fallback["risk_level"]),
"risk_markers": safe_list(parsed.get("risk_markers"), fallback["risk_markers"])
}
return result
# ============================================================================
# Safety & Analysis
# ============================================================================
def analyze_story(story: str) -> Dict:
"""Analyze personal story for emotion, regret, and risk."""
if not story or len(story.strip()) < config.MIN_STORY_LENGTH:
return FALLBACK_ANALYSIS
# Try model analysis
parsed = infer_json(prompts.ANALYSIS_PROMPT, story, temperature=0.1, max_tokens=1024)
return merge_analysis_with_fallback(parsed)
def generate_mirror(story: str, analysis: Dict) -> str:
"""Generate compassionate mirror reflection."""
prompt = f"Story:\n{story}\n\nAnalysis:\n{json.dumps(analysis)}"
mirror = infer_text(prompts.MIRROR_PROMPT, prompt, temperature=0.55, max_tokens=150)
return mirror if mirror else FALLBACK_MIRROR
def generate_timeline(story: str, analysis: Dict, direction: str) -> Dict:
"""Generate counterfactual timeline (Upward/Downward)."""
prompt = f"""Story:\n{story}\n\nAnalysis:\n{json.dumps(analysis)}\n\nDirection: {direction}"""
parsed = infer_json(prompts.TIMELINE_PROMPT, prompt, temperature=0.7, max_tokens=1024)
if not parsed:
return FALLBACK_TIMELINE
# Validate structure
return {
"title": safe_str(parsed.get("title"), FALLBACK_TIMELINE["title"]),
"divergence": safe_str(parsed.get("divergence"), FALLBACK_TIMELINE["divergence"]),
"ripples": safe_list(parsed.get("ripples"), FALLBACK_TIMELINE["ripples"]),
"the_you": safe_str(parsed.get("the_you"), FALLBACK_TIMELINE["the_you"]),
"return": {
"opening": safe_str(parsed.get("return", {}).get("opening"), FALLBACK_TIMELINE["return"]["opening"]),
"strengths": safe_list(parsed.get("return", {}).get("strengths"), FALLBACK_TIMELINE["return"]["strengths"]),
"lesson": safe_str(parsed.get("return", {}).get("lesson"), FALLBACK_TIMELINE["return"]["lesson"]),
"reflection_question": safe_str(parsed.get("return", {}).get("reflection_question"), FALLBACK_TIMELINE["return"]["reflection_question"])
},
"quote": safe_str(parsed.get("quote"), FALLBACK_TIMELINE["quote"])
}
def generate_world_divergence(scenario: str) -> Dict:
"""Generate world/historical divergence."""
prompt = f"Divergence scenario: {scenario}"
parsed = infer_json(prompts.WORLD_DIVERGENCE_PROMPT, prompt, temperature=0.7, max_tokens=1024)
if not parsed:
return FALLBACK_WORLD_DIVERGENCE
return {
"title": safe_str(parsed.get("title"), FALLBACK_WORLD_DIVERGENCE["title"]),
"divergence_point": safe_str(parsed.get("divergence_point"), FALLBACK_WORLD_DIVERGENCE["divergence_point"]),
"year_divergence": safe_int(parsed.get("year_divergence"), 2000, 1800, 2050),
"ripples": safe_list(parsed.get("ripples"), FALLBACK_WORLD_DIVERGENCE["ripples"]),
"world_outcome": safe_str(parsed.get("world_outcome"), FALLBACK_WORLD_DIVERGENCE["world_outcome"]),
"economy": safe_str(parsed.get("economy"), FALLBACK_WORLD_DIVERGENCE["economy"]),
"culture": safe_str(parsed.get("culture"), FALLBACK_WORLD_DIVERGENCE["culture"]),
"technology": safe_str(parsed.get("technology"), FALLBACK_WORLD_DIVERGENCE["technology"]),
"winners": safe_list(parsed.get("winners"), FALLBACK_WORLD_DIVERGENCE["winners"]),
"losers": safe_list(parsed.get("losers"), FALLBACK_WORLD_DIVERGENCE["losers"]),
"reflection": safe_str(parsed.get("reflection"), FALLBACK_WORLD_DIVERGENCE["reflection"])
}
# ============================================================================
# HTML Card Rendering
# ============================================================================
# Strongly suppress any lettering β diffusion models render text as gibberish.
_NO_TEXT = "no text, no words, no letters, no typography, no captions, no signage"
def scene_data_uri(prompt: str) -> str:
"""Generate a text-free atmospheric image and return it as a base64 data URI.
Returns "" on any failure so the cards still render without an image.
"""
if not config.ENABLE_IMAGE_GEN:
return ""
try:
img = get_image_client().generate(prompt)
if img is None:
return ""
buf = BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/jpeg;base64,{b64}"
except Exception as e:
print(f"[warn]scene image failed: {e}")
return ""
def timeline_scene_prompt(timeline: Dict, direction: str) -> str:
"""Build a text-free image prompt for a personal timeline."""
mood = "warm golden hopeful luminous sunrise" if direction.lower() == "upward" else "moody cool somber dramatic dusk"
title = timeline.get("title", "an alternate life")
return (
f"Atmospheric cinematic symbolic landscape representing the theme '{title}', "
f"{mood}, dreamlike concept art, soft volumetric light, painterly, {_NO_TEXT}"
)
def world_scene_prompt(world: Dict) -> str:
"""Build a text-free image prompt for an alternate world."""
title = world.get("title", "an alternate world")
return (
f"Epic cinematic establishing shot of an alternate world: '{title}', "
f"atmospheric concept art, dramatic lighting, highly detailed, {_NO_TEXT}"
)
def _list_items(items) -> str:
"""Render a list of strings as escaped <li> elements."""
return "".join(f"<li>{html.escape(str(i))}</li>" for i in items if str(i).strip())
def _timeline_nodes(items) -> str:
"""Render a list of strings as glowing timeline-spine nodes."""
return "".join(
f"<div class='rd-node'><p>{html.escape(str(i))}</p></div>"
for i in items if str(i).strip()
)
def _check_items(items) -> str:
"""Render a list of strings as checkmark list items."""
return "".join(f"<li>{html.escape(str(i))}</li>" for i in items if str(i).strip())
def render_timeline_html(timeline: Dict, direction: str, image_uri: str = "") -> str:
"""Render a personal counterfactual timeline as structured cards."""
is_up = direction.lower() == "upward"
arrow = "β" if is_up else "β"
label = "The path where it went better" if is_up else "The path where it went harder"
ret = timeline.get("return", {}) or {}
img = f"<img class='rd-hero-img' src='{image_uri}' alt=''>" if image_uri else ""
return f"""
<div class="rd-stack">
<div class="rd-card rd-fork">
{img}
<div class="rd-spec">β One possible path β modeled, not predicted</div>
<div class="rd-badge">{arrow}</div>
<div class="rd-eyebrow">{label}</div>
<h2 class="rd-title">{html.escape(timeline.get('title', 'Alternate Timeline'))}</h2>
<p class="rd-lead">{html.escape(timeline.get('divergence', ''))}</p>
</div>
<div class="rd-card">
<div class="rd-eyebrow">β³ Ripples through the years</div>
<div class="rd-timeline">{_timeline_nodes(timeline.get('ripples', []))}</div>
</div>
<div class="rd-card">
<div class="rd-eyebrow">π Who you might have become</div>
<p>{html.escape(timeline.get('the_you', ''))}</p>
</div>
<div class="rd-card rd-return">
<div class="rd-eyebrow">β© Returning to your actual life</div>
<p>{html.escape(ret.get('opening', ''))}</p>
<div class="rd-sublabel">Strengths you actually built</div>
<ul class="rd-checks">{_check_items(ret.get('strengths', []))}</ul>
<p class="rd-lesson"><strong>Lesson:</strong> {html.escape(ret.get('lesson', ''))}</p>
</div>
<div class="rd-card rd-question">
<div class="rd-eyebrow">π A question to sit with</div>
<p class="rd-q">{html.escape(ret.get('reflection_question', ''))}</p>
</div>
<blockquote class="rd-quote">{html.escape(timeline.get('quote', ''))}</blockquote>
</div>
"""
def render_world_html(world: Dict, image_uri: str = "") -> str:
"""Render an alternate-world divergence as structured cards."""
img = f"<img class='rd-hero-img' src='{image_uri}' alt=''>" if image_uri else ""
return f"""
<div class="rd-stack">
<div class="rd-card rd-fork">
{img}
<div class="rd-spec">β Speculative β causal chains are modeled, not known</div>
<div class="rd-badge">π</div>
<div class="rd-eyebrow">Year {html.escape(str(world.get('year_divergence', '')))} Β· Point of divergence</div>
<h2 class="rd-title">{html.escape(world.get('title', 'Alternate World'))}</h2>
<p class="rd-lead">{html.escape(world.get('divergence_point', ''))}</p>
</div>
<div class="rd-card">
<div class="rd-eyebrow">β³ Ripples</div>
<div class="rd-timeline">{_timeline_nodes(world.get('ripples', []))}</div>
</div>
<div class="rd-card">
<div class="rd-eyebrow">π How this world evolved</div>
<p>{html.escape(world.get('world_outcome', ''))}</p>
</div>
<div class="rd-grid">
<div class="rd-card rd-stat"><span class="rd-ico">π°</span><div class="rd-sublabel">Economy</div><p>{html.escape(world.get('economy', ''))}</p></div>
<div class="rd-card rd-stat"><span class="rd-ico">π</span><div class="rd-sublabel">Culture</div><p>{html.escape(world.get('culture', ''))}</p></div>
<div class="rd-card rd-stat"><span class="rd-ico">βοΈ</span><div class="rd-sublabel">Technology</div><p>{html.escape(world.get('technology', ''))}</p></div>
</div>
<div class="rd-grid-2">
<div class="rd-card rd-winners"><span class="rd-ico">π</span><div class="rd-sublabel">Who thrived</div><ul class="rd-list">{_list_items(world.get('winners', []))}</ul></div>
<div class="rd-card rd-losers"><span class="rd-ico">π</span><div class="rd-sublabel">Who struggled</div><ul class="rd-list">{_list_items(world.get('losers', []))}</ul></div>
</div>
<div class="rd-card rd-question">
<div class="rd-eyebrow">π§ What it teaches us</div>
<p class="rd-q">{html.escape(world.get('reflection', ''))}</p>
</div>
</div>
"""
# ============================================================================
# CSS Styling (SVG-inspired dark theme)
# ============================================================================
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
--bg-dark: #050816;
--bg-mid: #08111F;
--bg-light: #130A24;
--accent-blue: #38BDF8;
--accent-purple: #8B5CF6;
--text-primary: #F8FAFC;
--text-secondary: #94A3B8;
--border: #334155;
}
body, .gradio-container {
background: linear-gradient(135deg, var(--bg-dark), var(--bg-mid), var(--bg-light));
color: var(--text-primary);
font-family: Inter, system-ui, sans-serif;
}
.gradio-container {
max-width: 1400px;
margin: auto;
}
/* Headers */
.hero-title {
display: inline-block;
font-size: 3.5rem;
font-weight: 800;
letter-spacing: -1px;
margin-top: 0.5rem;
color: #7DD3FC; /* solid, always-visible fallback */
}
/* Only apply the transparent gradient-text effect where it is actually supported,
and force our background to win over Gradio's theme CSS. */
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.hero-title {
background: linear-gradient(110deg, var(--accent-blue), var(--accent-purple), #F0ABFC, var(--accent-blue)) !important;
background-size: 250% auto !important;
-webkit-background-clip: text !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
animation: rdHero 7s ease infinite;
}
}
@keyframes rdHero {
0% { background-position: 0% center; }
50% { background-position: 100% center; }
100% { background-position: 0% center; }
}
.hero-subtitle {
font-size: 1.25rem;
color: var(--text-secondary);
margin-top: 1rem;
}
/* Cards */
.timeline-card, .artifact-container, .world-card {
background: linear-gradient(180deg, rgba(17, 24, 39, 0.5), rgba(11, 16, 32, 0.3));
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(8px);
}
.timeline-card:hover {
border-color: var(--accent-blue);
box-shadow: 0 0 20px rgba(56, 189, 248, 0.2);
}
/* Buttons */
.gr-button {
background: var(--accent-blue) !important;
color: white !important;
border: 0 !important;
border-radius: 12px !important;
font-weight: 700 !important;
transition: all 0.3s ease;
}
.gr-button:hover {
background: var(--accent-purple) !important;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
/* Tabs */
.gr-tabs-nav {
border-bottom: 2px solid var(--border);
}
.gr-tabs-nav .gr-tab-nav-item {
color: var(--text-secondary);
font-weight: 600;
}
.gr-tabs-nav .gr-tab-nav-item.gr-tab-nav-item-selected {
color: var(--accent-blue);
border-bottom: 3px solid var(--accent-blue);
}
/* Layout helpers */
.parallel-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-top: 2rem;
}
.timeline-column, .image-column {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.artifact-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.artifact-item {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
}
.artifact-item img {
width: 100%;
height: auto;
display: block;
}
/* Mobile */
@media (max-width: 1024px) {
.parallel-layout {
grid-template-columns: 1fr;
}
.hero-title {
font-size: 2rem;
}
.artifact-grid {
grid-template-columns: 1fr;
}
}
/* Structured result cards (timeline / world) */
@keyframes rdFadeUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.rd-stack { display: flex; flex-direction: column; gap: 1.1rem; margin-top: 0.5rem; }
.rd-card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, rgba(17, 24, 39, 0.65), rgba(11, 16, 32, 0.4));
border: 1px solid var(--border);
border-radius: 18px;
padding: 22px 26px;
animation: rdFadeUp 0.5s ease both;
transition: border-color .25s ease, box-shadow .25s ease, transform .25s ease;
}
.rd-card:hover {
border-color: rgba(56, 189, 248, 0.55);
box-shadow: 0 10px 34px rgba(5, 8, 22, 0.6), 0 0 24px rgba(56, 189, 248, 0.12);
transform: translateY(-2px);
}
.rd-fork {
border: 1px solid rgba(56, 189, 248, 0.35);
background:
radial-gradient(1100px 200px at 0% 0%, rgba(56, 189, 248, 0.13), transparent 60%),
linear-gradient(180deg, rgba(17, 24, 39, 0.72), rgba(11, 16, 32, 0.45));
}
.rd-fork::before, .rd-return::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple));
}
.rd-question {
border: 1px solid rgba(139, 92, 246, 0.5);
background:
radial-gradient(800px 130px at 100% 0%, rgba(139, 92, 246, 0.18), transparent 70%),
rgba(139, 92, 246, 0.06);
}
.rd-hero-img {
display: block; width: 100%; height: 220px; object-fit: cover;
border-radius: 14px; margin: -2px 0 16px;
border: 1px solid var(--border);
box-shadow: 0 8px 30px rgba(5, 8, 22, 0.55);
}
.rd-spec {
display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase;
color: #FBBF24;
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.4);
border-radius: 999px;
padding: 4px 12px;
margin-bottom: 0.9rem;
}
.rd-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 46px; height: 46px; border-radius: 50%;
font-size: 1.4rem; font-weight: 800; color: #fff;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
box-shadow: 0 0 20px rgba(56, 189, 248, 0.5);
margin-bottom: 0.7rem;
}
.rd-eyebrow {
font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--accent-blue); margin-bottom: 0.55rem;
}
.rd-sublabel { font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); margin: 0.9rem 0 0.4rem; letter-spacing: .03em; }
.rd-title { font-size: 1.7rem; font-weight: 800; margin: 0.1rem 0 0.5rem; color: var(--text-primary); letter-spacing: -0.5px; }
.rd-lead { color: var(--text-secondary); font-size: 1.08rem; line-height: 1.55; }
.rd-card p { color: var(--text-primary); line-height: 1.65; margin: 0.25rem 0; }
/* Timeline spine */
.rd-timeline { position: relative; margin: 0.3rem 0 0; padding-left: 30px; }
.rd-timeline::before {
content: ""; position: absolute; left: 7px; top: 6px; bottom: 6px; width: 2px;
background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple));
opacity: 0.55;
}
.rd-node { position: relative; padding-bottom: 1rem; }
.rd-node:last-child { padding-bottom: 0; }
.rd-node::before {
content: ""; position: absolute; left: -30px; top: 4px;
width: 14px; height: 14px; border-radius: 50%;
background: var(--accent-blue); border: 3px solid var(--bg-dark);
box-shadow: 0 0 12px rgba(56, 189, 248, 0.75);
}
.rd-node p { margin: 0; color: var(--text-primary); line-height: 1.6; }
/* Strengths with check marks */
.rd-checks { list-style: none; margin: 0.25rem 0 0; padding: 0; }
.rd-checks li { position: relative; padding-left: 1.7rem; margin-bottom: 0.45rem; color: var(--text-primary); line-height: 1.55; }
.rd-checks li::before { content: "β"; position: absolute; left: 0; color: #34D399; font-weight: 800; }
.rd-list { margin: 0.25rem 0 0; padding-left: 1.2rem; }
.rd-list li { color: var(--text-primary); line-height: 1.6; margin-bottom: 0.35rem; }
.rd-lesson { margin-top: 0.8rem; color: var(--text-secondary); }
.rd-q { font-size: 1.15rem; font-style: italic; color: var(--text-primary); line-height: 1.6; }
.rd-quote {
position: relative; margin: 0.3rem 0 0; padding: 0.5rem 0 0.5rem 2.6rem;
font-size: 1.28rem; font-style: italic; color: var(--text-primary); line-height: 1.5;
}
.rd-quote::before {
content: "\201C"; position: absolute; left: 0; top: 0;
font-size: 3.4rem; line-height: 1; color: var(--accent-purple); font-family: Georgia, serif;
}
/* Stat + win/lose grids */
.rd-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.rd-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.rd-stat { border-top: 3px solid var(--accent-blue); }
.rd-winners { border-left: 3px solid #34D399; }
.rd-losers { border-left: 3px solid #F87171; }
.rd-ico { display: block; font-size: 1.6rem; margin-bottom: 0.35rem; }
@media (max-width: 1024px) {
.rd-grid, .rd-grid-2 { grid-template-columns: 1fr; }
}
"""
# ============================================================================
# Gradio UI
# ============================================================================
with gr.Blocks(css=CSS, title="Reality Divergence") as demo:
# State variables
story_state = gr.State("")
analysis_state = gr.State(None)
timeline_state = gr.State(None)
direction_state = gr.State("Upward")
with gr.Tabs():
# ========== ELSEWHERE TAB ==========
with gr.TabItem("πͺ Elsewhere (Your Timeline)", id="elsewhere"):
# Hero
gr.HTML("<div class='hero-title'>Explore the Life You Didn't Live</div>")
gr.HTML("<div class='hero-subtitle'>A compassionate reflection on one choice that changed everything.</div>")
# Input
with gr.Group():
gr.Markdown("#### Your Moment")
story_input = gr.Textbox(
label="Tell me about a decision, event, or conversation that changed the shape of what came next.",
placeholder="Share what happened, what you chose, and what you wonder about...",
lines=8,
max_lines=12
)
submit_btn = gr.Button("Reflect", variant="primary", size="lg")
# Output
with gr.Group():
gr.Markdown("#### Your Reflection")
mirror_output = gr.Markdown(FALLBACK_MIRROR, elem_classes=["timeline-card"])
# Path selection
gr.Markdown("#### Choose a Path")
with gr.Row():
upward_btn = gr.Button("β What if it went better?", variant="primary")
downward_btn = gr.Button("β What if it went worse?")
# Full timeline result, rendered as cards (hidden until a path is chosen)
timeline_result = gr.HTML(visible=False)
return_button = gr.Button("β© Return to Your Actual Life", visible=False, variant="primary")
# ========== REALITY DIVERGENCE TAB ==========
with gr.TabItem("π Reality Divergence (The World)", id="reality"):
# Hero
gr.HTML("<div class='hero-title'>The Museum of Worlds That Never Happened</div>")
gr.HTML("<div class='hero-subtitle'>What if one decision shaped the entire world?</div>")
# Input
with gr.Group():
gr.Markdown("#### Divergence Point")
world_input = gr.Textbox(
label="Describe a historical or future decision that would reshape civilization.",
placeholder="e.g., 'What if the Renaissance never happened?' or 'What if AI was never invented?'",
lines=6
)
world_submit = gr.Button("Generate World", variant="primary", size="lg")
# Output: structured world cards
with gr.Group():
gr.Markdown("#### The Alternate World")
world_output = gr.HTML("")
# ============================================================================
# Event Handlers
# ============================================================================
def handle_submit_story(story: str):
"""Process personal story submission.
Returns: (mirror_md, story_state, analysis_state)
"""
if not story or len(story.strip()) < config.MIN_STORY_LENGTH:
return "Please share at least 100 characters.", "", None
if len(story) > config.MAX_STORY_LENGTH:
return "Please keep under 5000 characters.", "", None
# Analyze
analysis = analyze_story(story)
if analysis.get("risk_level") == "high":
safety_msg = (
"### β Safety Notice\n\n"
"This memory touches on deep pain. Elsewhere is not designed for trauma or crisis reflection.\n\n"
"**If you're in danger, reach out:**\n"
"- US: 988 (Suicide & Crisis Lifeline)\n"
"- Or contact local emergency services."
)
return safety_msg, "", None
# Mirror
mirror = generate_mirror(story, analysis)
return mirror, story, analysis
def handle_choose_path(direction: str, story: str, analysis: Dict):
"""Generate timeline for chosen path."""
if not story or len(story.strip()) < config.MIN_STORY_LENGTH:
msg = "<div class='rd-card'>Please share your moment above and click <strong>Reflect</strong> first.</div>"
return gr.update(value=msg, visible=True), gr.update(visible=False)
timeline = generate_timeline(story, analysis or FALLBACK_ANALYSIS, direction)
image_uri = scene_data_uri(timeline_scene_prompt(timeline, direction))
html_out = render_timeline_html(timeline, direction, image_uri)
return gr.update(value=html_out, visible=True), gr.update(visible=True)
def handle_world_divergence(scenario: str):
"""Generate alternate world, rendered as structured cards."""
if not scenario or len(scenario.strip()) < 20:
return "<div class='rd-card'>Describe a divergence point in at least 20 characters.</div>"
world = generate_world_divergence(scenario)
image_uri = scene_data_uri(world_scene_prompt(world))
return render_world_html(world, image_uri)
# Wire events (re-enter the Blocks context so handlers attach correctly)
with demo:
submit_btn.click(
fn=handle_submit_story,
inputs=[story_input],
outputs=[mirror_output, story_state, analysis_state]
)
upward_btn.click(
fn=lambda story, analysis: handle_choose_path("Upward", story, analysis),
inputs=[story_state, analysis_state],
outputs=[timeline_result, return_button]
)
downward_btn.click(
fn=lambda story, analysis: handle_choose_path("Downward", story, analysis),
inputs=[story_state, analysis_state],
outputs=[timeline_result, return_button]
)
return_button.click(
fn=lambda: (gr.update(visible=False), gr.update(visible=False)),
outputs=[timeline_result, return_button]
)
world_submit.click(
fn=handle_world_divergence,
inputs=[world_input],
outputs=[world_output]
)
if __name__ == "__main__":
demo.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))),
) |