| """Constellation of Hope — wishes become stars in a cosmic sky.""" |
|
|
| from __future__ import annotations |
|
|
| import json |
| import math |
| import os |
| import random |
| import uuid |
| from datetime import datetime, timezone |
| from functools import partial |
| from pathlib import Path |
|
|
| import gradio as gr |
|
|
| from model import generate_poetic_saying, review_wish, synthesize_whisper |
|
|
| CATEGORY_HUES = { |
| "SHAPE": 0.55, |
| "BOON": 0.12, |
| "JOURNEY": 0.70, |
| "BOND": 0.95, |
| "TRIBUTE": 0.38, |
| } |
| from starscape import Starscape |
|
|
| def _stars_file() -> Path: |
| data_dir = Path("/data") |
| if data_dir.is_dir() and os.access(data_dir, os.W_OK): |
| return data_dir / "stars.json" |
| return Path(__file__).parent / "stars.json" |
|
|
|
|
| STARS_FILE = _stars_file() |
| print(f"[stars] persistence path: {STARS_FILE}", flush=True) |
|
|
|
|
| def load_stars() -> list[dict]: |
| if not STARS_FILE.exists(): |
| return [] |
| try: |
| return json.loads(STARS_FILE.read_text(encoding="utf-8")) |
| except (json.JSONDecodeError, OSError): |
| return [] |
|
|
|
|
| def save_stars(stars: list[dict]) -> None: |
| tmp = STARS_FILE.with_suffix(".json.tmp") |
| tmp.write_text(json.dumps(stars, indent=2), encoding="utf-8") |
| tmp.replace(STARS_FILE) |
|
|
|
|
| def random_star_position() -> tuple[float, float, float]: |
| radius = 6 + random.random() * 14 |
| theta = random.random() * 2 * 3.141592653589793 |
| phi = math.acos(2 * random.random() - 1) |
| x = radius * math.sin(phi) * math.cos(theta) |
| y = radius * math.sin(phi) * math.sin(theta) |
| z = radius * math.cos(phi) |
| return round(x, 2), round(y, 2), round(z, 2) |
|
|
|
|
| def add_wish(wish: str, state: dict): |
| wish_text = (wish or "").strip() |
| hide = gr.update(visible=False) |
| show = gr.update(visible=True) |
|
|
| if not wish_text: |
| return ( |
| state, state, |
| "Please share a wish before sending it to the stars.", "", |
| hide, hide, "", |
| ) |
|
|
| verdict = review_wish(wish_text) |
| print(f"[add_wish] verdict={verdict}", flush=True) |
|
|
| if verdict["kind"] == "nonsense": |
| content = ( |
| "This wish didn't quite take shape. Try sharing a hope, dream, " |
| "or intention you'd like to send among the stars.\n\n" |
| f"**You wrote:** _{wish_text}_" |
| ) |
| return state, state, "", "", hide, show, content |
|
|
| final_wish = wish_text |
| rewritten = verdict["kind"] == "rewrite" |
| if rewritten: |
| final_wish = verdict["rewrite"] |
|
|
| category = verdict.get("category") or "SHAPE" |
| hue = CATEGORY_HUES.get(category, 0.55) |
|
|
| saying = generate_poetic_saying(final_wish) |
| x, y, z = random_star_position() |
| star = { |
| "id": str(uuid.uuid4()), |
| "wish": final_wish, |
| "saying": saying, |
| "category": category, |
| "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), |
| "x": x, |
| "y": y, |
| "z": z, |
| "hue": hue, |
| "size": round(0.18 + random.random() * 0.22, 3), |
| } |
|
|
| stars = list(state.get("stars", [])) |
| stars.append(star) |
| save_stars(stars) |
| new_state = {"stars": stars, "highlight_id": star["id"]} |
|
|
| if rewritten: |
| content = ( |
| "Your wish was gently reshaped into a kinder form before it joined the stars.\n\n" |
| f"**Original:** _{wish_text}_\n\n" |
| f"**Reshaped:** _{final_wish}_" |
| ) |
| return ( |
| new_state, new_state, |
| "Your star has joined the constellation.", saying, |
| hide, show, content, |
| ) |
|
|
| return ( |
| new_state, new_state, |
| "Your star has joined the constellation.", saying, |
| show, hide, "", |
| ) |
|
|
|
|
| CUSTOM_CSS = """ |
| .gradio-container { |
| background: radial-gradient(circle at top, #0f1028 0%, #050510 45%, #000 100%) !important; |
| } |
| .constellation-header { |
| text-align: center; |
| color: #eef0ff; |
| margin-bottom: 0.5rem; |
| } |
| .constellation-header h1 { |
| font-weight: 300; |
| letter-spacing: 0.06em; |
| margin-bottom: 0.25rem; |
| } |
| .constellation-header p { |
| color: rgba(210, 215, 245, 0.75); |
| max-width: 640px; |
| margin: 0 auto; |
| } |
| .gradio-container textarea, |
| .gradio-container input[type="text"] { |
| color: #eef0ff !important; |
| background: rgba(4, 6, 20, 0.85) !important; |
| caret-color: #eef0ff !important; |
| } |
| .gradio-container textarea::placeholder, |
| .gradio-container input[type="text"]::placeholder { |
| color: rgba(180, 190, 230, 0.45) !important; |
| } |
| .wisdom-modal { |
| width: min(640px, 100%); |
| margin: 1rem auto !important; |
| padding: 1.4rem 1.6rem !important; |
| border-radius: 16px !important; |
| background: rgba(8, 10, 28, 0.92) !important; |
| border: 1px solid rgba(147, 160, 255, 0.4) !important; |
| backdrop-filter: blur(14px); |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); |
| } |
| .wisdom-modal-title { |
| font-size: 0.8rem; |
| letter-spacing: 0.14em; |
| text-transform: uppercase; |
| color: rgba(200, 210, 255, 0.85); |
| margin-bottom: 0.85rem; |
| text-align: center; |
| } |
| .wisdom-modal-close { |
| margin-top: 0.5rem; |
| } |
| .progress-modal { |
| width: min(640px, 100%); |
| margin: 1.5rem auto !important; |
| padding: 1.6rem 1.8rem !important; |
| border-radius: 16px !important; |
| background: rgba(8, 10, 28, 0.7) !important; |
| border: 1px solid rgba(180, 200, 255, 0.35) !important; |
| text-align: center; |
| animation: progressGlow 3.2s ease-in-out infinite; |
| } |
| .progress-modal-title { |
| font-size: 0.78rem; |
| letter-spacing: 0.18em; |
| text-transform: uppercase; |
| color: rgba(190, 205, 255, 0.85); |
| margin-bottom: 0.9rem; |
| animation: progressShimmer 2.6s ease-in-out infinite; |
| } |
| .progress-modal p, |
| .progress-modal .prose, |
| .progress-modal .prose p { |
| font-size: 1.1rem; |
| font-style: italic; |
| color: rgba(230, 235, 255, 0.92) !important; |
| margin: 0; |
| } |
| @keyframes progressGlow { |
| 0%, 100% { |
| box-shadow: 0 0 30px rgba(147, 160, 255, 0.15); |
| transform: translateY(0); |
| } |
| 50% { |
| box-shadow: 0 0 60px rgba(147, 160, 255, 0.4); |
| transform: translateY(-4px); |
| } |
| } |
| @keyframes progressShimmer { |
| 0%, 100% { opacity: 0.65; } |
| 50% { opacity: 1; } |
| } |
| .tts-bridge { |
| opacity: 0.001; |
| height: 1px !important; |
| overflow: hidden; |
| margin: 0 !important; |
| padding: 0 !important; |
| } |
| .tts-player { |
| display: none !important; |
| width: 100%; |
| margin: 1rem 0 !important; |
| padding: 1.1rem 1.4rem !important; |
| border-radius: 14px !important; |
| background: rgba(8, 10, 28, 0.85) !important; |
| border: 1px solid rgba(180, 200, 255, 0.32) !important; |
| box-shadow: 0 12px 36px rgba(0, 0, 0, 0.4); |
| animation: ttsGlow 2.8s ease-in-out infinite; |
| } |
| .tts-player.visible { |
| display: block !important; |
| } |
| .tts-player-header { |
| display: flex; |
| align-items: center; |
| gap: 0.6rem; |
| margin-bottom: 0.55rem; |
| } |
| .tts-player-indicator { |
| font-size: 1.25rem; |
| color: rgba(220, 225, 255, 0.95); |
| animation: ttsBeat 0.9s ease-in-out infinite; |
| } |
| .tts-player-title { |
| font-size: 0.78rem; |
| letter-spacing: 0.16em; |
| text-transform: uppercase; |
| color: rgba(190, 205, 255, 0.85); |
| } |
| .tts-player-text { |
| font-size: 1.0rem; |
| font-style: italic; |
| color: rgba(230, 235, 255, 0.92); |
| margin-bottom: 0.75rem; |
| } |
| @keyframes ttsGlow { |
| 0%, 100% { box-shadow: 0 12px 36px rgba(0, 0, 0, 0.4); } |
| 50% { box-shadow: 0 12px 36px rgba(147, 160, 255, 0.32); } |
| } |
| @keyframes ttsBeat { |
| 0%, 100% { transform: scale(1); opacity: 0.75; } |
| 50% { transform: scale(1.2); opacity: 1; } |
| } |
| """ |
|
|
|
|
| APP_THEME = gr.themes.Base( |
| primary_hue="indigo", |
| secondary_hue="purple", |
| neutral_hue="slate", |
| ).set( |
| body_background_fill="transparent", |
| block_background_fill="rgba(8, 10, 28, 0.72)", |
| block_border_color="rgba(147, 160, 255, 0.25)", |
| body_text_color="#eef0ff", |
| input_background_fill="rgba(4, 6, 20, 0.85)", |
| input_background_fill_focus="rgba(4, 6, 20, 0.95)", |
| input_border_color="rgba(147, 160, 255, 0.35)", |
| input_placeholder_color="rgba(180, 190, 230, 0.45)", |
| ) |
|
|
|
|
| def build_app() -> gr.Blocks: |
| initial_state = {"stars": load_stars()} |
|
|
| with gr.Blocks(title="Constellation of Hope") as demo: |
| gr.HTML( |
| """ |
| <div class="constellation-header"> |
| <h1>Constellation of Hope</h1> |
| <p> |
| Share a wish with the cosmos. Qwen 2.5 will weave it into |
| a poetic star and place it among the constellations for all to see. |
| </p> |
| </div> |
| """ |
| ) |
|
|
| sky_state = gr.State(initial_state) |
| sky = Starscape(value=initial_state, height=1120, elem_id="constellation-sky") |
|
|
| with gr.Column(visible=False, elem_classes=["progress-modal"]) as progress_modal: |
| gr.HTML("<div class='progress-modal-title'>Your wish drifts toward the stars…</div>") |
| progress_text = gr.Markdown("") |
|
|
| with gr.Column(visible=False, elem_classes=["wisdom-modal"]) as wisdom_modal: |
| gr.HTML("<div class='wisdom-modal-title'>A Star's Wisdom</div>") |
| status = gr.Textbox(label="Status", interactive=False) |
| latest_saying = gr.Textbox( |
| label="Star wisdom", |
| interactive=False, |
| lines=4, |
| ) |
| close_modal_btn = gr.Button("Close", elem_classes=["wisdom-modal-close"]) |
|
|
| with gr.Column(visible=False, elem_classes=["wisdom-modal", "review-modal"]) as review_modal: |
| gr.HTML("<div class='wisdom-modal-title'>A Gentle Pause</div>") |
| review_content = gr.Markdown("") |
| close_review_btn = gr.Button("Close", elem_classes=["wisdom-modal-close"]) |
|
|
| |
| |
| with gr.Column(elem_id="tts-player", elem_classes=["tts-player"]) as tts_player: |
| gr.HTML( |
| "<div class='tts-player-header'>" |
| "<span class='tts-player-indicator'>♪</span>" |
| "<span class='tts-player-title'>Now whispering</span>" |
| "</div>" |
| "<div class='tts-player-text' id='tts-player-text'></div>" |
| ) |
| tts_audio = gr.Audio( |
| elem_id="tts-audio", |
| elem_classes=["tts-audio"], |
| autoplay=True, |
| interactive=False, |
| label="", |
| ) |
|
|
| wish_input = gr.Textbox( |
| label="Your wish", |
| placeholder="I hope my family finds peace this year...", |
| lines=3, |
| max_lines=5, |
| ) |
| submit_btn = gr.Button("Release your wish to the stars", variant="primary") |
|
|
| |
| with gr.Row(elem_classes=["tts-bridge"]): |
| tts_text_in = gr.Textbox( |
| elem_id="tts-text-in", |
| elem_classes=["tts-text-in"], |
| label="", |
| ) |
| tts_trigger = gr.Button( |
| "Speak", |
| elem_id="tts-trigger", |
| elem_classes=["tts-trigger"], |
| ) |
|
|
| tts_trigger.click(fn=synthesize_whisper, inputs=tts_text_in, outputs=tts_audio) |
|
|
| submit_btn.click( |
| fn=lambda w: (gr.update(visible=True), f"_{(w or '').strip()}_"), |
| inputs=wish_input, |
| outputs=[progress_modal, progress_text], |
| ).then( |
| fn=add_wish, |
| inputs=[wish_input, sky_state], |
| outputs=[sky, sky_state, status, latest_saying, |
| wisdom_modal, review_modal, review_content], |
| ).then( |
| fn=lambda: gr.update(visible=False), |
| outputs=progress_modal, |
| ).then(lambda: "", outputs=wish_input) |
|
|
| close_modal_btn.click(lambda: gr.update(visible=False), outputs=wisdom_modal) |
| close_review_btn.click(lambda: gr.update(visible=False), outputs=review_modal) |
|
|
| demo.load(lambda s: s, inputs=sky_state, outputs=sky) |
|
|
| return demo |
|
|
|
|
| demo = build_app() |
|
|
| LAUNCH_KWARGS = { |
| "theme": APP_THEME, |
| "css": CUSTOM_CSS, |
| "ssr_mode": False, |
| } |
|
|
| demo.launch = partial(demo.launch, **LAUNCH_KWARGS) |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|