""" 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'#{html.escape(t)}' for t in (p.tags or [])[:3]) return f"""
{html.escape(p.name or p.name_zh)}
{html.escape(_subtitle(p))}
{tags}
""" 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""" """ 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("" for _ in range(9)) def _voice_html(audio: str) -> str: # Voice message — tappable waveform + embedded audio (matches Mac VoiceMessageView). if not audio: return "" return ('' + _WAV_BARS + '' '') 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'
{html.escape(label)}' f'' f'{v:+.2f}
') 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 ('
INNER STATE
' '
Awaken a persona to watch her mind move — ' 'relationship, drives, temperature and reward shift with every turn.
') 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'
Inner monologue
' f'
"{html.escape(mono)}"
') if mono else "" return (f'
INNER STATE
' f'
Dominant drive · {html.escape(dom)}
' f'
Relationship & metabolism
{rel_rows}' f'
Drives
{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'
' if uri else f'
{html.escape((p.name or "?")[0])}
') if not msgs and not typing: body = '
✧✦✧
Tuning…
' 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'') 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'
') elif content: safe = html.escape(content).replace("\n", "
") retry = ('' if role == "assistant" and content.startswith("(error:") else "") msg = f'
{safe}{retry}
' else: msg = "" # image-only message rows.append(f'
{img_html}{msg}{voice}' f'
{html.escape(ts)}
') if typing: rows.append('
' '
') 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'
{_param_panel(status)}
' if status else "" return f"""
{av}
{html.escape(p.name)}
{body}
{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'' 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"""
{video_html}
{html.escape(name)}
{html.escape(mbti)}
""" 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 """ _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")])