openher / app.py
kellyxiaowei's picture
Faithful voice: tap-to-play (no autoplay) + voice only when engine modality is voice (not every reply), matching Mac
29e6745 verified
Raw
History Blame Contribute Delete
50.7 kB
"""
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&nbsp;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&nbsp;STATE</div>'
f'<div class="oh-pdom">Dominant drive · {html.escape(dom)}</div>'
f'<div class="oh-side-sec">Relationship &amp; 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")])