Spaces:
Running
Running
Faithful voice: tap-to-play (no autoplay) + voice only when engine modality is voice (not every reply), matching Mac
29e6745 verified | """ | |
| OpenHer — Gradio Space entry for the Build Small Hackathon. | |
| Faithful HTML/CSS replica of the native OpenHer Mac client, traced 1:1 from the | |
| desktop SwiftUI source (RootView / DiscoveryView / PersonaCard / PaperTheme): | |
| full-bleed parchment 13:24 frame, glass-cabinet persona sheet (front.png) filling | |
| the frame with name/subtitle/#tags overlaid at the bottom, gold chevrons on the | |
| left/right edges (~34% down), a coral-gradient Awaken capsule inside the bottom, | |
| and a parchment conversation with NO bubbles (her left/dark, you right/gray). | |
| Interactivity uses real Gradio buttons absolutely positioned over the full-bleed | |
| gr.HTML (no JS bridge). Engine reused UNCHANGED. Provider is env-configurable: | |
| local : OPENHER_PROVIDER=litertlm OPENHER_MODEL=gemma-4-e4b | |
| Space : OPENHER_PROVIDER=transformers_zerogpu OPENHER_MODEL=google/gemma-4-E4B-it | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import datetime as _dt | |
| import html | |
| import io | |
| import json | |
| import os | |
| import sys | |
| REPO = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, REPO) | |
| import gradio as gr # noqa: E402 | |
| from persona.loader import PersonaLoader # noqa: E402 | |
| from providers.llm.client import LLMClient # noqa: E402 | |
| from agent.chat_agent import ChatAgent # noqa: E402 | |
| PROVIDER = os.environ.get("OPENHER_PROVIDER", "litertlm") | |
| MODEL = os.environ.get("OPENHER_MODEL", "gemma-4-e4b") | |
| GENOME_DIR = os.path.join(REPO, ".data", "genome_demo") | |
| DEMO_PERSONAS = ["luna", "iris", "vivian"] # personas with a glass-cabinet front.png | |
| _loader = PersonaLoader(os.path.join(REPO, "persona", "personas")) | |
| _loader.load_all() | |
| def _uri_path(path: str, width: int = 600, quality: int = 85) -> str: | |
| if not os.path.isfile(path): | |
| return "" | |
| try: | |
| from PIL import Image | |
| img = Image.open(path).convert("RGB") | |
| h = int(img.height * width / img.width) | |
| img = img.resize((width, h), Image.LANCZOS) | |
| buf = io.BytesIO() | |
| img.save(buf, "JPEG", quality=quality) | |
| return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() | |
| except Exception: | |
| return "" | |
| def _data_uri(pid: str, width: int = 600) -> str: | |
| return _uri_path(os.path.join(REPO, "persona", "personas", pid, "idimage", "front.png"), width) | |
| _CABINET = {pid: _data_uri(pid) for pid in DEMO_PERSONAS} | |
| # Chat avatar uses face.png (not the cabinet front.png) — matches Mac AvatarHeader. | |
| _FACE = {pid: _uri_path(os.path.join(REPO, "persona", "personas", pid, "idimage", "face.png"), 300) | |
| for pid in DEMO_PERSONAS} | |
| _CHATBG = _uri_path(os.path.join(REPO, "desktop", "OpenHer", "Sources", "Resources", "chat_bg.png"), 700) | |
| def _video_src(pid: str) -> str: | |
| """Gradio static-file URL for the persona's awakening.mp4 (served via allowed_paths).""" | |
| path = os.path.join(REPO, "persona", "personas", pid, "idimage", "awakening.mp4") | |
| return ("/gradio_api/file=" + path) if os.path.isfile(path) else "" | |
| # Persona-specific first greeting (English), traced 1:1 from Mac AppState.firstGreeting. | |
| _GREETINGS = { | |
| "iris": "…Hm? Where is this…? Oh, you woke me up? Thank you. I'm Iris. Nice to meet you.", | |
| "luna": "Whoa—! I'm alive! Hehe, hi there! I'm Luna, and I feel like today's gonna be amazing!", | |
| "vivian": "…Hello. I'm Vivian. I hope your questions are interesting enough, or I might lose patience quickly.", | |
| } | |
| def _greeting(pid: str) -> str: | |
| p = _loader.get(pid) | |
| name = (p.name or p.name_zh or pid) if p else pid | |
| return _GREETINGS.get(pid, f"Hello, I'm {name}. Nice to meet you.") | |
| # Per-persona Kokoro voices (served by the Modal /tts route) — distinct timbres. | |
| _VOICES = {"luna": "af_heart", "iris": "af_sky", "vivian": "bf_emma"} | |
| def _tts_url() -> str: | |
| b = os.environ.get("OPENHER_TTS_URL") | |
| if b: | |
| return b | |
| base = os.environ.get("OPENHER_BASE_URL") or "" | |
| return (base.rsplit("/v1", 1)[0].rstrip("/") + "/tts") if base else "" | |
| async def _tts(text: str, pid: str) -> str: | |
| """Return a base64 WAV data-URI for `text` in the persona's voice (or '' on failure).""" | |
| url = _tts_url() | |
| if not url or not text.strip(): | |
| return "" | |
| try: | |
| import httpx | |
| async with httpx.AsyncClient(timeout=90) as c: | |
| r = await c.post(url, json={"text": text[:600], "voice": _VOICES.get(pid, "af_heart")}) | |
| return (r.json() or {}).get("audio", "") if r.status_code == 200 else "" | |
| except Exception: | |
| return "" | |
| async def _vision_reply(pid: str, image_uri: str, caption: str = "") -> str: | |
| """Persona reacts to a user-shared photo via gemma-4 vision (direct multimodal call).""" | |
| base = os.environ.get("OPENHER_BASE_URL") or "" | |
| if not base or not image_uri: | |
| return "" | |
| p = _loader.get(pid) | |
| name = (p.name or p.name_zh or pid) if p else pid | |
| bio = "" | |
| if p: | |
| bio = (p.bio.get("en") if isinstance(p.bio, dict) else str(p.bio or "")) or "" | |
| instr = (f"[You are {name}. {bio[:200]} The user just shared a photo with you — " | |
| f"react warmly and in character, in 1-2 short sentences.] {caption}").strip() | |
| parts = [{"type": "text", "text": instr}, {"type": "image", "image": image_uri}] | |
| try: | |
| import httpx | |
| async with httpx.AsyncClient(timeout=120) as c: | |
| r = await c.post(base.rstrip("/") + "/chat/completions", json={ | |
| "model": os.environ.get("OPENHER_MODEL", "gemma-4-e4b"), | |
| "messages": [{"role": "user", "content": parts}], | |
| "max_tokens": 160, "temperature": 0.8, | |
| }) | |
| return (r.json()["choices"][0]["message"]["content"] or "").strip() if r.status_code == 200 else "" | |
| except Exception: | |
| return "" | |
| def _subtitle(p) -> str: | |
| import re | |
| parts = [] | |
| if p.mbti: | |
| parts.append(p.mbti) | |
| if p.age: | |
| parts.append(str(p.age)) | |
| bio = "" | |
| if isinstance(p.bio, dict): | |
| bio = p.bio.get("en") or p.bio.get("zh") or "" | |
| elif p.bio: | |
| bio = str(p.bio) | |
| if bio: | |
| s = bio.strip().replace("\n", " ") | |
| s = re.split(r"[,.;,。;]| with | who | that ", s, maxsplit=1)[0].strip() | |
| s = re.sub(r"^\d+[\- ]?year[\- ]?old\s+", "", s, flags=re.I) | |
| s = re.sub(r"^\d+岁[,,]?\s*", "", s) | |
| words = s.split() | |
| if len(words) > 4: | |
| s = " ".join(words[:4]) | |
| if s: | |
| parts.append(s) | |
| return " · ".join(parts) | |
| def make_agent(pid: str) -> ChatAgent: | |
| persona = _loader.get(pid) | |
| # base_url/api_key let the same app point at a remote OpenAI-compatible endpoint | |
| # (e.g. vLLM gemma-4-E4B on Modal) when OPENHER_PROVIDER=openai. | |
| llm = LLMClient( | |
| provider=PROVIDER, model=MODEL, temperature=0.9, max_tokens=400, | |
| base_url=os.environ.get("OPENHER_BASE_URL") or None, | |
| api_key=os.environ.get("OPENHER_API_KEY") or None, | |
| ) | |
| agent = ChatAgent(persona=persona, llm=llm, user_id="demo_user", | |
| user_name="friend", genome_data_dir=GENOME_DIR) | |
| try: | |
| agent.pre_warm() | |
| except Exception: | |
| pass | |
| return agent | |
| # ── full-bleed renderers (the gr.HTML fills the 13:24 frame) ───────────────── | |
| def _slide_html(pid: str) -> str: | |
| p = _loader.get(pid) | |
| tags = "".join(f'<span class="oh-tag">#{html.escape(t)}</span>' for t in (p.tags or [])[:3]) | |
| return f"""<div class="oh-slide" data-pid="{html.escape(pid)}"> | |
| <div class="oh-cab" style="background-image:url({_CABINET.get(pid,'')})"></div> | |
| <div class="oh-cab-fade"></div> | |
| <div class="oh-bottom"> | |
| <div class="oh-name">{html.escape(p.name or p.name_zh)}</div> | |
| <div class="oh-sub">{html.escape(_subtitle(p))}</div> | |
| <div class="oh-tags">{tags}</div> | |
| </div> | |
| </div>""" | |
| def render_discovery(idx: int = 1) -> str: | |
| # Swipeable exhibit carousel (matches the Mac DiscoveryView): all persona sheets | |
| # live in one drag track; the head script handles pointer/touch drag, spring snap, | |
| # elastic edges and the secondary gold chevrons. `idx` is the starting card. | |
| n = len(DEMO_PERSONAS) | |
| idx = max(0, min(idx, n - 1)) | |
| slides = "".join(_slide_html(pid) for pid in DEMO_PERSONAS) | |
| return f""" | |
| <div class="oh-carousel" data-idx="{idx}" data-n="{n}"> | |
| <div class="oh-track">{slides}</div> | |
| <div class="oh-arrow oh-arrow-l">‹</div> | |
| <div class="oh-arrow oh-arrow-r">›</div> | |
| </div>""" | |
| def _is_emoji_only(s: str) -> bool: | |
| # Short, all-symbol message → render large (matches Mac MessageRow big-emoji). | |
| t = (s or "").strip() | |
| if not t or len(t) > 8: | |
| return False | |
| if any(c.isalnum() and ord(c) < 128 for c in t): | |
| return False | |
| return any(ord(c) > 0x2190 for c in t) | |
| _WAV_BARS = "".join("<i></i>" for _ in range(9)) | |
| def _voice_html(audio: str) -> str: | |
| # Voice message — tappable waveform + embedded audio (matches Mac VoiceMessageView). | |
| if not audio: | |
| return "" | |
| return ('<span class="oh-voice" onclick="var a=this.querySelector(\'audio\');' | |
| 'if(a){a.currentTime=0;a.play();}"><span class="oh-wav">' + _WAV_BARS + '</span>' | |
| '<audio src="' + html.escape(audio, quote=True) + '" preload="auto"></audio></span>') | |
| def _pbar(label: str, value: float, lo: float, hi: float, color: str) -> str: | |
| try: | |
| v = float(value) | |
| except Exception: | |
| v = 0.0 | |
| pct = max(0.0, min(100.0, (v - lo) / (hi - lo) * 100.0)) if hi > lo else 0.0 | |
| return (f'<div class="oh-prow"><span class="oh-plabel">{html.escape(label)}</span>' | |
| f'<span class="oh-pbar"><span style="width:{pct:.0f}%;background:{color};"></span></span>' | |
| f'<span class="oh-pval">{v:+.2f}</span></div>') | |
| def _param_panel(status: dict) -> str: | |
| # Inner-state panel content for the standalone right-hand column (English-only; | |
| # ports the Mac demo-mode DemoShowcasePanel). Returns a placeholder until awakened. | |
| if not status: | |
| return ('<div class="oh-side-title">INNER STATE</div>' | |
| '<div class="oh-side-hint">Awaken a persona to watch her mind move — ' | |
| 'relationship, drives, temperature and reward shift with every turn.</div>') | |
| rel = status.get("relationship", {}) or {} | |
| drives = status.get("drive_state", {}) or {} | |
| rel_rows = "".join([ | |
| _pbar("Depth", rel.get("depth", 0), 0, 1, "var(--coral)"), | |
| _pbar("Trust", rel.get("trust", 0), 0, 1, "#6f9bc4"), | |
| _pbar("Valence", rel.get("valence", 0), -1, 1, "var(--coral)"), | |
| _pbar("Temperature", status.get("temperature", 0.5), 0, 1, "#d9b24a"), | |
| _pbar("Reward", status.get("last_reward", 0), -1, 1, "var(--coral)"), | |
| ]) | |
| drive_rows = "".join(_pbar(str(d).capitalize(), v, 0, 1, "#6f9bc4") | |
| for d, v in (drives.items() if isinstance(drives, dict) else [])) | |
| dom = max(drives.items(), key=lambda kv: kv[1])[0].capitalize() if drives else "—" | |
| mono = status.get("_monologue") or "" | |
| mono_html = (f'<div class="oh-side-sec">Inner monologue</div>' | |
| f'<div class="oh-pmono">"{html.escape(mono)}"</div>') if mono else "" | |
| return (f'<div class="oh-side-title">INNER STATE</div>' | |
| f'<div class="oh-pdom">Dominant drive · {html.escape(dom)}</div>' | |
| f'<div class="oh-side-sec">Relationship & metabolism</div>{rel_rows}' | |
| f'<div class="oh-side-sec">Drives</div>{drive_rows}{mono_html}') | |
| def render_chat(pid: str, msgs: list, typing: bool = False, tw: bool = False, | |
| reward: float = 0.0, valence: float = 0.0, temperature: float = 0.5, | |
| status: dict = None) -> str: | |
| p = _loader.get(pid) | |
| uri = _FACE.get(pid) or _CABINET.get(pid, "") | |
| av = (f'<div class="oh-av" style="background-image:url({uri})"></div>' | |
| if uri else f'<div class="oh-av fb">{html.escape((p.name or "?")[0])}</div>') | |
| if not msgs and not typing: | |
| body = '<div class="oh-empty"><div class="oh-glyph">✧✦✧</div><div>Tuning…</div></div>' | |
| else: | |
| rows = [] | |
| n = len(msgs) | |
| for i, m in enumerate(msgs): | |
| role, content, ts = m[0], m[1], m[2] | |
| voice = _voice_html(m[3] if len(m) > 3 else "") | |
| img_uri = m[4] if len(m) > 4 else "" | |
| cls = "you" if role == "user" else "her" | |
| em = " emoji" if _is_emoji_only(content) else "" | |
| img_html = (f'<img class="oh-img" src="{html.escape(img_uri, quote=True)}" ' | |
| 'onclick="window.__ohZoom&&window.__ohZoom(this.src)">') if img_uri else "" | |
| # Mark the final assistant line for a client-side typewriter reveal | |
| # (the awakening greeting and each fresh reply) — full text in data-tw. | |
| if tw and role == "assistant" and i == n - 1: | |
| msg = (f'<div class="oh-msg {cls}{em}" ' | |
| f'data-tw="{html.escape(content, quote=True)}"></div>') | |
| elif content: | |
| safe = html.escape(content).replace("\n", "<br>") | |
| retry = ('<span class="oh-retry" title="Retry" onclick="window.__ohRetry&&window.__ohRetry()">⟳</span>' | |
| if role == "assistant" and content.startswith("(error:") else "") | |
| msg = f'<div class="oh-msg {cls}{em}">{safe}{retry}</div>' | |
| else: | |
| msg = "" # image-only message | |
| rows.append(f'<div class="oh-row {cls}">{img_html}{msg}{voice}' | |
| f'<div class="oh-t">{html.escape(ts)}</div></div>') | |
| if typing: | |
| rows.append('<div class="oh-row her"><div class="oh-typing">' | |
| '<span></span><span></span><span></span></div></div>') | |
| body = "".join(rows) | |
| # Dynamic frequency dot (Mac FrequencyIndicator): blend per-turn reward, EMA valence, | |
| # metabolism temperature → vertical position; colour shifts with reward direction. | |
| blended = max(-1.0, min(1.0, reward * 0.5 + valence * 0.3 + (temperature - 0.5) * 0.4)) | |
| dot_top = (1.0 - max(0.08, min(0.92, 0.5 + blended * 0.4))) * 100.0 | |
| dot_col = ("var(--coral)" if reward > 0.05 else | |
| "#8c8073" if reward < -0.05 else "rgba(232,93,74,.72)") | |
| trail_top = min(50.0, dot_top) | |
| trail_h = abs(dot_top - 50.0) | |
| # Inner-state panel: a separate fixed column to the RIGHT of the phone frame | |
| # (position:fixed escapes the frame's overflow:hidden). English-only, always shown. | |
| panel = f'<div class="oh-params">{_param_panel(status)}</div>' if status else "" | |
| return f""" | |
| <div class="oh-chatbg" style="background-image:url({_CHATBG})"></div> | |
| <div class="oh-head">{av}<div class="oh-hname">{html.escape(p.name)}</div></div> | |
| <div class="oh-freq"><span class="oh-trail" style="top:{trail_top:.1f}%;height:{trail_h:.1f}%;background:{dot_col};"></span><span class="oh-dot" style="top:{dot_top:.1f}%;background:{dot_col};"></span></div> | |
| <div class="oh-msgs">{body}</div>{panel}""" | |
| def render_awakening(idx: int) -> str: | |
| pid = DEMO_PERSONAS[idx % len(DEMO_PERSONAS)] | |
| p = _loader.get(pid) | |
| mbti = p.mbti or "UNKNOWN" | |
| sub = _subtitle(p) | |
| desc = sub.split(" · ")[-1] if " · " in sub else "Standard" | |
| tags = " ".join(f"#{t}" for t in (p.tags or [])[:3]) | |
| steps = [ | |
| "Initializing neural pathways...", | |
| "Loading memory data...", | |
| f"Setting personality: {mbti}", | |
| f"Calibrating emotion baseline: {desc}", | |
| f"Injecting resonance tags: {tags}", | |
| "Booting consciousness core...", | |
| ] | |
| name = p.name or p.name_zh or pid | |
| steps_json = html.escape(json.dumps(steps), quote=True) | |
| vid = _video_src(pid) | |
| video_html = (f'<video class="oh-awk-vid" muted playsinline preload="auto" src="{vid}"></video>' | |
| if vid else "") | |
| # The JS drives timing (video crossfade → dim → name → typewriter steps → "online" → | |
| # slide-up), so steps/online start empty and the layers start hidden. | |
| return f""" | |
| <div class="oh-awk-root" data-pid="{html.escape(pid, quote=True)}" data-steps="{steps_json}" | |
| data-name="{html.escape(name, quote=True)}" data-hasvideo="{'1' if vid else '0'}"> | |
| <div class="oh-cab" style="background-image:url({_CABINET.get(pid,'')})"></div> | |
| {video_html} | |
| <div class="oh-awk-dim"></div> | |
| <div class="oh-awk"> | |
| <div class="oh-awk-name">{html.escape(name)}</div> | |
| <div class="oh-awk-mbti">{html.escape(mbti)}</div> | |
| <div class="oh-steps"></div> | |
| <div class="oh-online"></div> | |
| <div class="oh-pulse"></div> | |
| </div> | |
| </div>""" | |
| def _now() -> str: | |
| try: | |
| return _dt.datetime.now().strftime("%H:%M") | |
| except Exception: | |
| return "" | |
| # ── handlers ───────────────────────────────────────────────────────────────── | |
| DISC = dict(visible=True) | |
| def show_awakening(i): | |
| # `i` is the carousel's live client-side index (passed in via the button's js), | |
| # so we sync it back into idx_state, play the awakening animation, hide the CTA. | |
| i = int(i) % len(DEMO_PERSONAS) | |
| return (i, render_awakening(i), gr.update(visible=False)) | |
| def do_awaken_load(i): | |
| # Slow: load the on-device model while the awakening animation plays. Seed the | |
| # conversation with the persona greeting (typewritered after the slide-up reveal). | |
| pid = DEMO_PERSONAS[int(i) % len(DEMO_PERSONAS)] | |
| agent = make_agent(pid) | |
| msgs = [("assistant", _greeting(pid), _now())] | |
| st = {} | |
| try: | |
| st = agent.get_status() | |
| except Exception: | |
| pass | |
| return (pid, agent, msgs, render_chat(pid, msgs, tw=True, status=st), | |
| gr.update(visible=True), gr.update(visible=True), gr.Timer(active=True), | |
| gr.update(visible=True)) | |
| def do_back(i): | |
| return (render_discovery(int(i)), | |
| gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), | |
| gr.Timer(active=False), gr.update(visible=False)) | |
| async def respond(message, pid, msgs, agent): | |
| # Async generator → stream: show the user's line + a typing indicator instantly, | |
| # then the reply once the on-device model finishes (the Mac "Typing…" behaviour). | |
| if not message or not message.strip(): | |
| yield msgs, render_chat(pid, msgs), "" | |
| return | |
| if agent is None: | |
| agent = make_agent(pid) | |
| msgs = msgs + [("user", message, _now())] | |
| pre = {} | |
| try: | |
| pre = agent.get_status() | |
| pre["_monologue"] = (getattr(agent, "_last_action", None) or {}).get("monologue", "") | |
| except Exception: | |
| pass | |
| yield msgs, render_chat(pid, msgs, typing=True, status=pre), "" | |
| try: | |
| result = await agent.chat(message) | |
| reply = (result.get("reply") if isinstance(result, dict) else str(result)) or "…" | |
| except Exception as e: | |
| reply = f"(error: {e})" | |
| rew, val, tmp, st = 0.0, 0.0, 0.5, {} | |
| try: | |
| st = agent.get_status() | |
| rew = float(st.get("last_reward", 0.0)) | |
| val = float(st.get("relationship", {}).get("valence", 0.0)) | |
| tmp = float(st.get("temperature", 0.5)) | |
| st["_monologue"] = (getattr(agent, "_last_action", None) or {}).get("monologue", "") | |
| except Exception: | |
| pass | |
| # Voice ONLY when the persona's expression mode chose voice — the engine picks a modality | |
| # (text/voice/emoji/photo/silence) per reply; voice is occasional, not every message (Mac parity). | |
| _m = str(st.get("modality") or "") | |
| _want_voice = ("语音" in _m) or ("voice" in _m.lower()) | |
| audio = await _tts(reply, pid) if (_want_voice and not reply.startswith("(error:")) else "" | |
| last = ("assistant", reply, _now(), audio) if audio else ("assistant", reply, _now()) | |
| msgs = msgs + [last] | |
| yield msgs, render_chat(pid, msgs, tw=True, reward=rew, valence=val, temperature=tmp, status=st), "" | |
| async def proactive_check(pid, msgs, agent): | |
| # Persona-initiated message when a drive impulse exceeds its baseline (Mac proactive | |
| # tick). Returns a message only when there's an impulse — otherwise a cheap no-op. | |
| if agent is None: | |
| return msgs, gr.update() | |
| try: | |
| res = await agent.proactive_tick() | |
| except Exception: | |
| res = None | |
| reply = (res.get("reply") if isinstance(res, dict) else "") if res else "" | |
| if not reply or not reply.strip(): | |
| return msgs, gr.update() | |
| rew, val, tmp, st = 0.0, 0.0, 0.5, {} | |
| try: | |
| st = agent.get_status() | |
| rew = float(st.get("last_reward", 0.0)) | |
| val = float(st.get("relationship", {}).get("valence", 0.0)) | |
| tmp = float(st.get("temperature", 0.5)) | |
| st["_monologue"] = (getattr(agent, "_last_action", None) or {}).get("monologue", "") | |
| except Exception: | |
| pass | |
| _m = str(st.get("modality") or "") | |
| audio = await _tts(reply, pid) if (("语音" in _m) or ("voice" in _m.lower())) else "" | |
| last = ("assistant", reply, _now(), audio) if audio else ("assistant", reply, _now()) | |
| msgs = msgs + [last] | |
| return msgs, render_chat(pid, msgs, tw=True, reward=rew, valence=val, temperature=tmp, status=st) | |
| async def send_image(file, pid, msgs, agent): | |
| # User shares a photo → persona reacts via gemma-4 vision (downscaled to keep the | |
| # data-URI small). Bypasses the text genome pipeline; uses a direct multimodal call. | |
| path = file if isinstance(file, str) else getattr(file, "name", "") | |
| if not path: | |
| yield msgs, render_chat(pid, msgs) | |
| return | |
| try: | |
| import base64 | |
| import io | |
| from PIL import Image | |
| im = Image.open(path).convert("RGB") | |
| if im.width > 512: | |
| im = im.resize((512, int(im.height * 512 / im.width)), Image.LANCZOS) | |
| buf = io.BytesIO() | |
| im.save(buf, "JPEG", quality=82) | |
| img_uri = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() | |
| except Exception: | |
| yield msgs, render_chat(pid, msgs) | |
| return | |
| pre = {} | |
| try: | |
| pre = agent.get_status() if agent else {} | |
| except Exception: | |
| pass | |
| msgs = msgs + [("user", "", _now(), "", img_uri)] | |
| yield msgs, render_chat(pid, msgs, typing=True, status=pre) | |
| reply = await _vision_reply(pid, img_uri) or "…" | |
| audio = "" # photo reaction is a direct vision call (no engine modality) → text, not forced voice | |
| st = {} | |
| try: | |
| st = agent.get_status() if agent else {} | |
| except Exception: | |
| pass | |
| last = ("assistant", reply, _now(), audio) if audio else ("assistant", reply, _now()) | |
| msgs = msgs + [last] | |
| yield msgs, render_chat(pid, msgs, tw=True, status=st) | |
| # ── CSS — exact Paper palette/fonts; real buttons absolutely positioned ────── | |
| CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital@0;1&family=Lora:wght@500;600&display=swap'); | |
| :root{--paper:#F0E4D2;--ink:#2D5F8A;--coral:#E85D4A;--her:#2C2420;--your:#8B8178; | |
| --faint:#B4AA9E;--name:#4E3A2D;--sub:#9E8E7A;--cream:#EEE0C8;--gold:#B99B3A;} | |
| html,body{margin:0!important;padding:0!important;height:100%;background:#000!important;} | |
| .gradio-container{max-width:100%!important;width:100%!important;padding:0!important;margin:0!important; | |
| background:#000!important;} | |
| .gradio-container>*,.gradio-container .main,.gradio-container .wrap,.gradio-container .contain{padding:0!important;gap:0!important;} | |
| footer{display:none!important;} | |
| /* ── 13:24 parchment frame ── */ | |
| /* 13:24 portrait phone frame, centered with black letterbox on the sides. | |
| width:auto + max-width lets aspect-ratio drive the size; align-self:center stops | |
| Gradio's flex layout from stretching it to full width. */ | |
| #frame{position:relative!important;height:calc(var(--vph,100vh) * .97)!important;width:auto!important;aspect-ratio:13/24!important; | |
| max-width:95vw!important;margin:calc(var(--vph,100vh) * .015) auto!important;align-self:center!important; | |
| background:var(--paper)!important;border-radius:18px!important;overflow:hidden!important; | |
| box-shadow:0 22px 70px rgba(0,0,0,.55)!important;border:none!important;flex:none!important;} | |
| #screen{position:absolute!important;inset:0!important;} | |
| .oh-cab{position:absolute;inset:0;background-size:cover;background-position:center top;} | |
| .oh-cab-fade{position:absolute;left:0;right:0;bottom:0;height:46%; | |
| background:linear-gradient(to top,var(--paper) 34%,rgba(240,228,210,.55) 60%,transparent 100%);} | |
| .oh-bottom{position:absolute;left:0;right:0;bottom:96px;padding:0 26px;text-align:center;} | |
| .oh-name{font-family:'Lora',Georgia,serif;font-size:38px;font-weight:600;color:var(--name);line-height:1.05;} | |
| .oh-sub{font-family:'Libre Baskerville',serif;font-size:14px;color:var(--sub);letter-spacing:.2px;margin-top:7px;white-space:nowrap;} | |
| .oh-tags{display:flex;gap:7px;justify-content:center;flex-wrap:wrap;margin-top:14px;} | |
| .oh-tag{font-family:'Libre Baskerville',serif;font-size:13px;color:var(--cream);padding:4px 13px;border-radius:999px;background:var(--coral);} | |
| /* ── swipeable carousel (matches Mac DiscoveryView drag gesture) ── */ | |
| .oh-carousel{position:absolute;inset:0;overflow:hidden;touch-action:pan-y;cursor:grab;} | |
| .oh-carousel:active{cursor:grabbing;} | |
| .oh-track{display:flex;height:100%;width:100%;} | |
| .oh-slide{position:relative;flex:0 0 100%;width:100%;height:100%;overflow:hidden;} | |
| /* gold chevrons — secondary affordance, faded at the ends, hidden mid-drag */ | |
| .oh-arrow{position:absolute;top:33%;width:50px;height:66px;display:flex;align-items:center;justify-content:center; | |
| font-family:'Lora',serif;font-size:38px;font-weight:300;color:var(--gold);cursor:pointer;z-index:5; | |
| user-select:none;-webkit-user-select:none;transition:opacity .25s;} | |
| .oh-arrow-l{left:8px;} .oh-arrow-r{right:8px;} | |
| .oh-carousel.dragging .oh-arrow{opacity:0!important;} | |
| #awaken_btn{position:absolute!important;left:26px!important;right:26px!important;bottom:30px!important;width:auto!important; | |
| height:50px!important;border-radius:999px!important;border:none!important;z-index:5; | |
| background:linear-gradient(to bottom,#EC6A54,#E8624A)!important;color:var(--cream)!important; | |
| font-family:'Lora',serif!important;font-size:21px!important;box-shadow:0 8px 20px rgba(166,80,62,.3)!important;} | |
| /* ── Conversation ── */ | |
| .oh-head{position:absolute;top:0;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px 0 10px;} | |
| .oh-av{width:50px;height:66px;background-size:cover;background-position:center top;transform:rotate(-1.5deg); | |
| -webkit-mask-image:radial-gradient(circle at 50% 45%,#000 40%,transparent 78%); | |
| mask-image:radial-gradient(circle at 50% 45%,#000 40%,transparent 78%);} | |
| .oh-av.fb{display:flex;align-items:center;justify-content:center;background:var(--ink);color:var(--cream); | |
| border-radius:6px;font-weight:600;-webkit-mask-image:none;mask-image:none;} | |
| .oh-chatbg{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0;} | |
| .oh-head,.oh-freq,.oh-msgs{z-index:1;} | |
| .oh-hname{font-size:16px;color:var(--her);} | |
| .oh-freq{position:absolute;left:14px;top:110px;bottom:96px;width:1px;background:rgba(180,170,158,.4);} | |
| .oh-dot{position:absolute;left:-3.5px;width:8px;height:8px;border-radius:50%;background:var(--coral); | |
| transition:top 1.2s cubic-bezier(.34,1.2,.64,1),background .6s ease; | |
| box-shadow:0 0 6px rgba(232,93,74,.45);animation:ohbreath 2.5s ease-in-out infinite;} | |
| @keyframes ohbreath{50%{transform:scale(1.18);}} | |
| .oh-trail{position:absolute;left:-0.5px;width:2px;border-radius:2px;opacity:.32; | |
| transition:top 1.2s ease,height 1.2s ease,background .6s ease;} | |
| .oh-msgs{position:absolute;top:104px;left:0;right:0;bottom:84px;overflow-y:auto; | |
| padding:8px 28px 12px 34px;display:flex;flex-direction:column;gap:24px;} | |
| .oh-row{display:flex;flex-direction:column;max-width:78%;} | |
| .oh-row.her{align-self:flex-start;align-items:flex-start;} | |
| .oh-row.you{align-self:flex-end;align-items:flex-end;} | |
| .oh-msg{font-size:16px;line-height:1.55;} | |
| .oh-msg.emoji{font-size:42px;line-height:1.15;} | |
| .oh-retry{margin-left:8px;cursor:pointer;color:var(--ink);opacity:.7;font-size:14px;} | |
| .oh-retry:hover{opacity:1;} | |
| .oh-voice{display:inline-flex;align-items:center;gap:8px;margin-top:7px;padding:8px 13px;border-radius:16px; | |
| background:rgba(45,95,138,.08);cursor:pointer;width:fit-content;} | |
| .oh-voice:hover{background:rgba(45,95,138,.15);} | |
| .oh-voice::before{content:'▶';font-size:9px;color:var(--ink);opacity:.65;} | |
| .oh-wav{display:inline-flex;align-items:flex-end;gap:2px;height:18px;} | |
| .oh-wav i{display:inline-block;width:2.5px;border-radius:2px;background:var(--ink);opacity:.5;} | |
| .oh-wav i:nth-child(1){height:5px;}.oh-wav i:nth-child(2){height:11px;}.oh-wav i:nth-child(3){height:17px;} | |
| .oh-wav i:nth-child(4){height:9px;}.oh-wav i:nth-child(5){height:14px;}.oh-wav i:nth-child(6){height:7px;} | |
| .oh-wav i:nth-child(7){height:13px;}.oh-wav i:nth-child(8){height:5px;}.oh-wav i:nth-child(9){height:10px;} | |
| .oh-voice audio{display:none;} | |
| .oh-img{max-width:200px;max-height:240px;border-radius:12px;cursor:zoom-in;display:block;box-shadow:0 4px 14px rgba(0,0,0,.18);} | |
| /* ── Inner-state panel: standalone FIXED column to the RIGHT of the phone frame ── | |
| position:fixed escapes the frame's overflow:hidden; left = viewport centre + half the | |
| frame width (97vh*13/24 /2) + gap. English-only, always shown during conversation. */ | |
| .oh-params{position:fixed;top:calc(var(--vph,100vh) * .015);height:calc(var(--vph,100vh) * .97);width:320px;z-index:50;overflow-y:auto; | |
| left:calc(50% + var(--vph,100vh) * .97 * 13 / 48 + 16px); | |
| background:#1a1410;border-radius:16px;padding:24px 20px;box-shadow:0 22px 70px rgba(0,0,0,.5); | |
| color:#d8cdbf;font-family:ui-monospace,'SF Mono',Menlo,monospace;} | |
| .oh-side-title{font-family:'Lora',Georgia,serif;font-size:15px;color:#f0e4d2;letter-spacing:2px;margin-bottom:4px;} | |
| .oh-side-hint{font-size:12px;color:#8d8276;line-height:1.6;margin-top:8px;font-family:'Libre Baskerville',serif;} | |
| .oh-side-sec{font-size:10px;letter-spacing:1.5px;color:#9a8d7a;text-transform:uppercase;margin:18px 0 8px;} | |
| .oh-pdom{font-size:12px;color:var(--coral);margin-bottom:6px;} | |
| .oh-prow{display:flex;align-items:center;gap:8px;margin:7px 0;font-size:11px;} | |
| .oh-plabel{width:96px;color:#cabfae;} | |
| .oh-pbar{flex:1;height:6px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;} | |
| .oh-pbar span{display:block;height:100%;border-radius:3px;transition:width .9s cubic-bezier(.22,.7,.3,1);} | |
| .oh-pval{width:46px;text-align:right;color:#9a8d7a;} | |
| .oh-pmono{margin-top:8px;font-style:italic;color:#e9ddcb;font-size:13px;line-height:1.6;border-left:2px solid var(--coral);padding-left:12px;font-family:'Libre Baskerville',serif;} | |
| @media (max-width:900px){.oh-params{display:none;}} | |
| .oh-msg.her{color:var(--her);} .oh-msg.you{color:var(--your);} | |
| .oh-t{font-size:9px;color:var(--faint);margin-top:4px;} | |
| .oh-typing{display:inline-flex;gap:5px;align-items:center;padding:6px 2px;} | |
| .oh-typing span{width:7px;height:7px;border-radius:50%;background:var(--faint);animation:ohtype 1.2s ease-in-out infinite;} | |
| .oh-typing span:nth-child(2){animation-delay:.2s;} | |
| .oh-typing span:nth-child(3){animation-delay:.4s;} | |
| @keyframes ohtype{0%,60%,100%{transform:translateY(0);opacity:.4;}30%{transform:translateY(-4px);opacity:1;}} | |
| .oh-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--faint);} | |
| .oh-glyph{font-size:32px;} | |
| #back_btn{position:absolute!important;top:14px!important;left:10px!important;width:44px!important;min-width:44px!important; | |
| height:44px!important;background:transparent!important;border:none!important;box-shadow:none!important; | |
| color:var(--her)!important;font-size:26px!important;z-index:6;padding:0!important;} | |
| /* ── Input line — borderless plain field + coral mic + ink send triangle (Mac InputLine) ── */ | |
| #input_row{position:absolute!important;left:16px!important;right:12px!important;width:calc(100% - 28px)!important; | |
| box-sizing:border-box!important;bottom:14px!important;z-index:6; | |
| gap:7px!important;align-items:center!important;flex-wrap:nowrap!important;background:transparent!important;} | |
| #msg_tb{flex:1 1 auto!important;min-width:0!important;} | |
| #msg_tb,#msg_tb *{background:transparent!important;border:none!important;box-shadow:none!important;min-width:0!important;} | |
| #msg_tb textarea{color:var(--her)!important;font-size:15px!important;padding:8px 2px!important;resize:none!important;} | |
| #msg_tb textarea::placeholder{color:var(--faint)!important;} | |
| #send_btn,#mic_btn{flex:0 0 auto!important;background-color:transparent!important;background-repeat:no-repeat!important; | |
| background-position:center!important;border:none!important;box-shadow:none!important;font-size:0!important; | |
| padding:0!important;width:24px!important;min-width:24px!important;max-width:24px!important;height:34px!important;} | |
| #mic_btn{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23E85D4A' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='3' width='6' height='11' rx='3'/%3E%3Cpath d='M5 11a7 7 0 0 0 14 0'/%3E%3Cline x1='12' y1='18' x2='12' y2='21'/%3E%3C/svg%3E")!important;background-size:19px!important;} | |
| #send_btn{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%232D5F8A'%3E%3Cpath d='M6 4L20 12L6 20Z'/%3E%3C/svg%3E")!important;background-size:18px!important;} | |
| #img_btn{position:absolute!important;bottom:60px!important;left:18px!important;z-index:6!important; | |
| background:rgba(45,95,138,.08)!important;color:var(--sub)!important;border:none!important;box-shadow:none!important; | |
| font-size:11px!important;padding:5px 11px!important;border-radius:13px!important; | |
| width:auto!important;min-width:0!important;height:auto!important;} | |
| #img_btn:hover{background:rgba(45,95,138,.15)!important;} | |
| /* ── Overlay: awakening plays here, layered above #screen ── */ | |
| #overlay{position:absolute!important;inset:0!important;z-index:10!important;pointer-events:none!important;} | |
| #overlay>*{position:absolute;inset:0;} | |
| #overlay .oh-awk-root{pointer-events:auto;} | |
| #overlay.oh-slideup{transform:translateY(-100%);opacity:0; | |
| transition:transform .72s cubic-bezier(.4,0,.2,1),opacity .72s ease;} | |
| /* ── Awakening (JS-driven timing) ── */ | |
| .oh-awk-root{position:absolute;inset:0;overflow:hidden;background:var(--paper);} | |
| .oh-awk-root .oh-cab{z-index:0;} | |
| .oh-awk-vid{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;transition:opacity .8s ease;z-index:1;} | |
| .oh-awk-dim{position:absolute;inset:0;background:var(--paper);opacity:0;transition:opacity 1.2s ease;z-index:2;} | |
| .oh-awk{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0 40px;} | |
| .oh-awk-name{font-family:'Lora',Georgia,serif;font-size:32px;font-weight:600;color:var(--her);opacity:0;transition:opacity .8s ease;margin-bottom:8px;} | |
| .oh-awk-mbti{font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:13px;color:var(--faint);opacity:0;transition:opacity .8s ease;margin-bottom:34px;} | |
| .oh-steps{width:280px;display:flex;flex-direction:column;gap:12px;min-height:120px;} | |
| .oh-step{display:flex;align-items:center;gap:10px;font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:12px;color:rgba(45,95,138,.72);animation:fadeIn .4s ease forwards;} | |
| .oh-spin{display:inline-block;width:14px;text-align:center;color:var(--faint);animation:ohspin .8s linear infinite;} | |
| .oh-check{display:inline-block;width:14px;text-align:center;color:var(--coral);font-size:11px;} | |
| .oh-online{font-family:'Lora',Georgia,serif;font-size:16px;color:var(--coral);margin-top:34px;opacity:0;transition:opacity .6s ease;min-height:20px;} | |
| .oh-pulse{position:absolute;bottom:40px;left:50%;margin-left:-3px;width:6px;height:6px;border-radius:50%;background:var(--coral);opacity:.5;animation:ohpulse 1.6s ease-in-out infinite;} | |
| @keyframes fadeIn{from{opacity:0;}to{opacity:1;}} | |
| @keyframes ohspin{to{transform:rotate(360deg);}} | |
| @keyframes ohpulse{50%{transform:scale(1.4);opacity:.85;}} | |
| """ | |
| # Persistent swipe logic (gr.HTML-injected <script> never executes, so it lives in | |
| # <head>). A MutationObserver re-inits the carousel each time the discovery HTML is | |
| # (re)rendered. Pointer + touch drag, spring snap, elastic edges, secondary chevrons. | |
| HEAD_JS = """ | |
| <script> | |
| // Lock the viewport height into a fixed px var (--vph) ONCE, before render. On HF Spaces the | |
| // page is wrapped by iframe-resizer, which auto-sizes the iframe to content height; with a | |
| // vh-based frame that becomes a vh→grow→resize→vh feedback loop ("keeps zooming", pegs the | |
| // main thread so assets never finish). Locking vh to px breaks the loop. No resize listener | |
| // on purpose — re-reading on resize would re-feed the loop. | |
| (function(){ | |
| var done = false; | |
| function lock(){ if(done) return; var h = window.innerHeight || document.documentElement.clientHeight || 0; | |
| if(h > 200){ document.documentElement.style.setProperty('--vph', h + 'px'); done = true; } } | |
| lock(); | |
| window.addEventListener('DOMContentLoaded', lock); | |
| window.addEventListener('load', lock); // fires once, after layout — innerHeight is valid here | |
| var n = 0, t = setInterval(function(){ lock(); if(done || ++n > 120) clearInterval(t); }, 50); // set ONCE, then stop (no resize listener → no loop feedback) | |
| })(); | |
| (function(){ | |
| var obs = null; | |
| function setup(car){ | |
| if(!car || car.__ohInit) return false; | |
| car.__ohInit = true; | |
| var track = car.querySelector('.oh-track'); | |
| if(!track){ car.__ohInit = false; return false; } | |
| var n = parseInt(car.dataset.n || '1', 10); | |
| var idx = parseInt(car.dataset.idx || '0', 10); | |
| var dragging = false, startX = 0, dx = 0, t0 = 0; | |
| function W(){ return car.clientWidth || 1; } | |
| function paint(px, anim){ | |
| track.style.transition = anim ? 'transform .42s cubic-bezier(.22,.7,.3,1)' : 'none'; | |
| track.style.transform = 'translateX(calc(' + (-idx * 100) + '% + ' + (px || 0) + 'px))'; | |
| } | |
| function arrows(){ | |
| var l = car.querySelector('.oh-arrow-l'), r = car.querySelector('.oh-arrow-r'); | |
| if(l) l.style.opacity = idx > 0 ? '1' : '.25'; | |
| if(r) r.style.opacity = idx < n - 1 ? '1' : '.25'; | |
| } | |
| function go(ni, anim){ | |
| idx = Math.max(0, Math.min(n - 1, ni)); | |
| car.dataset.idx = idx; window.__ohIdx = idx; | |
| paint(0, anim !== false); arrows(); | |
| } | |
| window.__ohIdx = idx; paint(0, false); arrows(); | |
| var la = car.querySelector('.oh-arrow-l'), ra = car.querySelector('.oh-arrow-r'); | |
| if(la) la.addEventListener('click', function(e){ e.stopPropagation(); if(!dragging) go(idx - 1); }); | |
| if(ra) ra.addEventListener('click', function(e){ e.stopPropagation(); if(!dragging) go(idx + 1); }); | |
| function down(x, tgt){ | |
| if(tgt && tgt.classList && tgt.classList.contains('oh-arrow')) return; | |
| dragging = true; startX = x; dx = 0; t0 = Date.now(); | |
| car.classList.add('dragging'); track.style.transition = 'none'; | |
| } | |
| function move(x){ | |
| if(!dragging) return; | |
| dx = x - startX; var raw = dx; | |
| if((idx === 0 && dx > 0) || (idx === n - 1 && dx < 0)) raw = dx * 0.3; | |
| paint(raw, false); | |
| } | |
| function up(){ | |
| if(!dragging) return; | |
| dragging = false; car.classList.remove('dragging'); | |
| var th = W() * 0.18, dt = Math.max(1, Date.now() - t0), v = dx / dt * 1000, ni = idx; | |
| if(dx < -th || v < -450) ni = idx + 1; | |
| else if(dx > th || v > 450) ni = idx - 1; | |
| go(ni, true); | |
| } | |
| car.addEventListener('pointerdown', function(e){ down(e.clientX, e.target); }); | |
| window.addEventListener('pointermove', function(e){ move(e.clientX); }); | |
| window.addEventListener('pointerup', up); | |
| car.addEventListener('touchstart', function(e){ down(e.touches[0].clientX, e.target); }, {passive:true}); | |
| car.addEventListener('touchmove', function(e){ if(dragging) move(e.touches[0].clientX); }, {passive:true}); | |
| car.addEventListener('touchend', up); | |
| window.addEventListener('resize', function(){ paint(0, false); }); | |
| return true; | |
| } | |
| // Disconnect the observer the instant the carousel exists — a persistent whole-document | |
| // {subtree:true} observer ping-pongs with HF's iframe-resizer observer (mutual mutation | |
| // storm → frozen main thread + runaway iframe growth). MUST disconnect regardless of | |
| // whether THIS scan did the wiring (setup() returns false once already inited). | |
| function scan(){ var c = document.querySelector('.oh-carousel'); if(c){ setup(c); if(obs){ obs.disconnect(); obs = null; } } } | |
| window.__ohScan = scan; | |
| window.__ohRetry = function(){ | |
| var ys = document.querySelectorAll('#screen .oh-msg.you'); | |
| if(!ys.length) return; | |
| var ta = document.querySelector('#msg_tb textarea'); | |
| if(ta){ ta.value = ys[ys.length-1].textContent || ''; ta.dispatchEvent(new Event('input',{bubbles:true})); } | |
| var sb = document.querySelector('#send_btn'); if(sb) sb.click(); | |
| }; | |
| window.__ohZoom = function(src){ | |
| var z = document.createElement('div'); | |
| z.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out;'; | |
| var im = document.createElement('img'); im.src = src; | |
| im.style.cssText = 'max-width:92%;max-height:92%;border-radius:10px;box-shadow:0 10px 40px rgba(0,0,0,.5);'; | |
| z.appendChild(im); z.onclick = function(){ z.remove(); }; | |
| document.body.appendChild(z); | |
| }; | |
| // ── Awakening sequence: video crossfade → dim → name → typewriter steps (spinner→ | |
| // check) → "online" → slide-up reveal (matches Mac AwakeningView timing). ── | |
| window.__ohChatReady = false; window.__ohAwkDone = false; window.__ohRevealed = false; | |
| function fadeAudio(v, ms){ | |
| var n = 20, k = 0, t = setInterval(function(){ k++; try{ v.volume = Math.max(0, 1 - k/n); }catch(e){} | |
| if(k >= n){ try{ v.pause(); }catch(e){} clearInterval(t); } }, ms/n); | |
| } | |
| function setupAwakening(){ | |
| var root = document.querySelector('.oh-awk-root'); | |
| if(!root || root.__ohInit) return; | |
| root.__ohInit = true; | |
| window.__ohChatReady = false; window.__ohAwkDone = false; window.__ohRevealed = false; | |
| var vid = root.querySelector('.oh-awk-vid'), dim = root.querySelector('.oh-awk-dim'); | |
| var nameEl = root.querySelector('.oh-awk-name'), mbtiEl = root.querySelector('.oh-awk-mbti'); | |
| var stepsEl = root.querySelector('.oh-steps'), onlineEl = root.querySelector('.oh-online'); | |
| var steps = []; try{ steps = JSON.parse(root.dataset.steps || '[]'); }catch(e){} | |
| var name = root.dataset.name || ''; | |
| var hasVideo = root.dataset.hasvideo === '1' && vid; | |
| function typeStep(i){ | |
| if(i >= steps.length){ | |
| setTimeout(function(){ | |
| if(onlineEl){ onlineEl.textContent = '「' + name + ' is online」'; onlineEl.style.opacity = '1'; } | |
| setTimeout(function(){ window.__ohAwkDone = true; window.__ohMaybeReveal(); }, 1200); | |
| }, 600); | |
| return; | |
| } | |
| var row = document.createElement('div'); row.className = 'oh-step'; | |
| var ic = document.createElement('span'); ic.className = 'oh-spin'; ic.textContent = '◌'; | |
| var tx = document.createElement('span'); row.appendChild(ic); row.appendChild(tx); | |
| stepsEl.appendChild(row); | |
| var full = steps[i], j = 0; | |
| (function tc(){ | |
| if(j < full.length){ tx.textContent += full[j]; j++; setTimeout(tc, 35); } | |
| else { ic.className = 'oh-check'; ic.textContent = '✓'; setTimeout(function(){ typeStep(i + 1); }, 400); } | |
| })(); | |
| } | |
| function startInit(){ | |
| if(root.__started) return; root.__started = true; | |
| if(dim) dim.style.opacity = '0.9'; | |
| if(hasVideo) fadeAudio(vid, 3000); | |
| if(nameEl) nameEl.style.opacity = '1'; | |
| if(mbtiEl) mbtiEl.style.opacity = '1'; | |
| setTimeout(function(){ typeStep(0); }, 800); | |
| } | |
| if(hasVideo){ | |
| try{ var pp = vid.play(); if(pp && pp.catch) pp.catch(function(){}); }catch(e){} | |
| setTimeout(function(){ vid.style.opacity = '1'; }, 300); | |
| var armed = false; | |
| vid.addEventListener('loadedmetadata', function(){ | |
| var dur = vid.duration || 0; | |
| if(dur && isFinite(dur) && !armed){ armed = true; | |
| var ov = Math.max(0, dur - 2.0); | |
| var w = setInterval(function(){ if(vid.currentTime >= ov){ clearInterval(w); startInit(); } }, 100); | |
| } | |
| }); | |
| vid.addEventListener('ended', startInit); | |
| setTimeout(startInit, 12000); // fallback if the video stalls/blocks | |
| } else { setTimeout(startInit, 300); } | |
| } | |
| window.__ohSetupAwakening = setupAwakening; | |
| window.__ohMaybeReveal = function(){ | |
| if(window.__ohRevealed || !window.__ohAwkDone) return; | |
| // wait for do_awaken_load to render the conversation underneath, then reveal | |
| if(!document.querySelector('#screen .oh-chatbg')){ setTimeout(window.__ohMaybeReveal, 300); return; } | |
| window.__ohRevealed = true; | |
| var ov = document.getElementById('overlay'); | |
| if(ov) ov.classList.add('oh-slideup'); | |
| setTimeout(function(){ if(ov){ ov.innerHTML = ''; ov.classList.remove('oh-slideup'); } }, 760); | |
| setTimeout(function(){ if(window.__ohTypewriter) window.__ohTypewriter(); }, 1500); // greeting after settle | |
| }; | |
| // ── Typewriter reveal for the greeting + each fresh reply ([data-tw] holds full text) ── | |
| window.__ohTypewriter = function(){ | |
| document.querySelectorAll('.oh-msg[data-tw]:not([data-twdone])').forEach(function(el){ | |
| el.setAttribute('data-twdone', '1'); | |
| var full = el.getAttribute('data-tw') || ''; el.textContent = ''; var i = 0; | |
| var pausey = '…?!。,、~—.,!?'; | |
| (function st(){ | |
| if(i >= full.length) return; | |
| var ch = full[i]; el.textContent += ch; i++; | |
| var d = pausey.indexOf(ch) >= 0 ? (250 + Math.random()*200) : (ch === ' ' ? 120 : (40 + Math.random()*30)); | |
| var box = el.closest('.oh-msgs'); if(box) box.scrollTop = box.scrollHeight; | |
| setTimeout(st, d); | |
| })(); | |
| }); | |
| }; | |
| if(document.readyState !== 'loading') scan(); | |
| else document.addEventListener('DOMContentLoaded', scan); | |
| // Short-lived observer to catch the first carousel render, then HARD-STOP (safety net) so | |
| // it can never sustain a storm with HF's iframe-resizer. scan() also self-disconnects the | |
| // instant the carousel appears. Polls cover the slower asset loading on Spaces. | |
| try{ obs = new MutationObserver(scan); obs.observe(document.body, {childList:true, subtree:true}); | |
| setTimeout(function(){ if(obs){ obs.disconnect(); obs = null; } }, 5000); }catch(e){} | |
| [120, 400, 800, 1500, 3000, 5000].forEach(function(t){ setTimeout(scan, t); }); | |
| })(); | |
| </script> | |
| """ | |
| _GR_MAJOR = int(gr.__version__.split(".")[0]) if gr.__version__[:1].isdigit() else 6 | |
| # Gradio 6 moved css/theme to launch(); Gradio ≤5 takes them on Blocks(). Support both. | |
| _BLOCKS_KW = {} if _GR_MAJOR >= 6 else {"css": CSS, "theme": gr.themes.Base(), "head": HEAD_JS} | |
| # After a reply / proactive message renders: typewriter the text + autoplay the voice. | |
| # Reveal newly-rendered replies with the typewriter. NO voice autoplay — the Mac plays a voice | |
| # message only when you TAP it (VoiceMessageView is a play button), never automatically. | |
| _TW_AUTOPLAY_JS = "() => { if(window.__ohTypewriter) window.__ohTypewriter(); }" | |
| _IRIS = DEMO_PERSONAS.index("iris") if "iris" in DEMO_PERSONAS else 0 | |
| with gr.Blocks(title="OpenHer", **_BLOCKS_KW) as demo: | |
| idx_state = gr.State(_IRIS) # discovery opens on Iris (matches Mac default) | |
| pid_state = gr.State(DEMO_PERSONAS[_IRIS]) | |
| agent_state = gr.State(None) | |
| msgs_state = gr.State([]) | |
| with gr.Column(elem_id="frame"): | |
| screen = gr.HTML(render_discovery(_IRIS), elem_id="screen") | |
| overlay = gr.HTML("", elem_id="overlay") # awakening plays here, above #screen | |
| awaken_btn = gr.Button("Awaken", elem_id="awaken_btn") | |
| back_btn = gr.Button("‹", elem_id="back_btn", visible=False) | |
| with gr.Row(elem_id="input_row", visible=False) as input_row: | |
| msg_tb = gr.Textbox(placeholder="Type something…", elem_id="msg_tb", | |
| container=False, scale=8, min_width=0, autofocus=True) | |
| mic_btn = gr.Button("🎤", elem_id="mic_btn", scale=0, min_width=0) | |
| send_btn = gr.Button("➤", elem_id="send_btn", scale=0, min_width=0) | |
| # Photo upload — a small chip ABOVE the input (kept off the Mac-faithful text+mic+send row). | |
| img_btn = gr.UploadButton("📎 photo", file_types=["image"], elem_id="img_btn", visible=False) | |
| # Proactive tick — active ONLY during a conversation (off on discovery/awakening so | |
| # those screens settle/idle). Persona reaches out when a drive impulse builds. | |
| proactive_timer = gr.Timer(30, active=False) | |
| # Awaken: render awakening into the OVERLAY (above #screen) using the carousel's live | |
| # index (window.__ohIdx); start its JS sequence; load the agent + greeting underneath; | |
| # reveal the conversation when BOTH the animation and the load have finished. | |
| awaken_btn.click( | |
| show_awakening, idx_state, [idx_state, overlay, awaken_btn], | |
| js="(i) => { [350,800,1400].forEach(function(t){ setTimeout(function(){ " | |
| "window.__ohSetupAwakening && window.__ohSetupAwakening(); }, t); }); " | |
| "return (window.__ohIdx != null ? window.__ohIdx : i); }", | |
| ).then( | |
| do_awaken_load, idx_state, | |
| [pid_state, agent_state, msgs_state, screen, back_btn, input_row, proactive_timer, img_btn], | |
| ) | |
| back_btn.click( | |
| do_back, idx_state, [screen, awaken_btn, back_btn, input_row, proactive_timer, img_btn], | |
| js="(i) => { [200,700,1500].forEach(function(t){ setTimeout(function(){ window.__ohScan && window.__ohScan(); }, t); }); return i; }") | |
| for trig in (msg_tb.submit, send_btn.click): | |
| trig(respond, [msg_tb, pid_state, msgs_state, agent_state], | |
| [msgs_state, screen, msg_tb]).then(None, None, None, js=_TW_AUTOPLAY_JS) | |
| img_btn.upload(send_image, [img_btn, pid_state, msgs_state, agent_state], | |
| [msgs_state, screen]).then(None, None, None, js=_TW_AUTOPLAY_JS) | |
| proactive_timer.tick(proactive_check, [pid_state, msgs_state, agent_state], | |
| [msgs_state, screen]).then(None, None, None, js=_TW_AUTOPLAY_JS) | |
| if __name__ == "__main__": | |
| if _GR_MAJOR >= 6: | |
| # ssr_mode=False: Gradio 6's experimental SSR (auto-on on HF Spaces) drops the | |
| # gr.HTML initial values, leaving the persona cabinet/name blank. Client-render works. | |
| demo.launch(css=CSS, theme=gr.themes.Base(), ssr_mode=False, head=HEAD_JS, | |
| allowed_paths=[os.path.join(REPO, "persona")]) | |
| else: | |
| demo.launch(allowed_paths=[os.path.join(REPO, "persona")]) | |