Fabella / app.py
Kiy-K's picture
style(ui): add parent-friendly theme selector
cdc818d
Raw
History Blame Contribute Delete
70.6 kB
"""Fabella - small words for big questions.
A Gradio Server (FastAPI subclass) serves a custom HTML+CSS+JS page. The
parent describes a hard-to-explain situation; Fabella drafts a short,
kind, age-appropriate explanation, validated by a second small model.
Architecture (see modal_app.py for the server side):
Gemma 4 E4B (drafter, A10G) - writes the explanation
Nemotron-3 Nano 4B (judge, A10G) - multi-criteria review
"""
import os
import sys
import asyncio
import traceback
import base64
import json
import urllib.error
import urllib.request
import re
import uuid
import warnings
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault("HF_HUB_DISABLE_EXPERIMENTAL_WARNING", "1")
warnings.filterwarnings(
"ignore",
message=".*HTTP_422_UNPROCESSABLE_ENTITY.*",
)
warnings.filterwarnings(
"ignore",
message="OAuth is not supported outside of a Space environment.*",
)
def _silence_asyncio_invalid_fd_warning() -> None:
import asyncio
original_del = asyncio.BaseEventLoop.__del__
def safe_del(self):
try:
original_del(self)
except ValueError as exc:
if "Invalid file descriptor" not in str(exc):
raise
asyncio.BaseEventLoop.__del__ = safe_del
_silence_asyncio_invalid_fd_warning()
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from agent import run_agent
from schema import ExplainRequest
import memory as memory_layer
from safety import (
has_profanity,
sanitize_name,
sanitize_situation,
)
MODAL_DRAFTER_URL = os.environ.get(
"MODAL_DRAFTER_URL",
"https://khoitruong071510--fabella-serve-drafter.modal.run",
)
MODAL_JUDGE_URL = os.environ.get(
"MODAL_JUDGE_URL",
"https://khoitruong071510--fabella-serve-judge.modal.run",
)
MODAL_TTS_URL = os.environ.get(
"MODAL_TTS_URL",
"https://khoitruong071510--fabella-serve-tts.modal.run",
)
MODAL_ASR_URL = os.environ.get(
"MODAL_ASR_URL",
"https://khoitruong071510--fabella-asr-experiment-serve-asr.modal.run",
)
# HF Spaces bucket mount. The current Space has a writable bucket mounted at
# /models. We avoid a separate database cloud API by storing one minimal JSON
# file per parent (HF user or anonymous browser session) inside that bucket.
# This keeps persistence tied to HF infrastructure only.
DATA_DIR = Path(os.environ.get("FABELLA_DATA_DIR", "/data/fabella-data"))
_history_lock = Lock()
_MAX_MESSAGES = 80
_SAFE_KEY = re.compile(r"[^a-zA-Z0-9._-]+")
try:
from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth
except Exception:
attach_huggingface_oauth = None
parse_huggingface_oauth = None
try:
import spaces
@spaces.GPU(duration=1)
def _gpu_placeholder() -> str:
return "ok"
except ImportError:
pass
from gradio import Server
app = Server()
demo = app
if attach_huggingface_oauth is not None:
try:
attach_huggingface_oauth(app)
except Exception as e:
print(f"[auth] OAuth endpoints disabled: {type(e).__name__}: {e}", flush=True)
# Start the trace publisher. It no-ops on local dev (no HF_TOKEN) and is
# safe to call when HF_TOKEN is missing. See trace.py for the schema,
# anonymization rules, and opt-out semantics.
try:
import trace as _trace
_trace.publisher.start()
except Exception as e:
print(f"[traces] publisher failed to start: {type(e).__name__}: {e}", flush=True)
# The previous deployment ran a warmup ping to ``/health`` on each
# Modal endpoint at Space startup, so the first parent click would land
# on a warm container. We removed that ping: the Space restarts several
# times per day (code pushes, env-var changes, HF rebalances), and each
# restart paid for an A10G cold start whether or not a parent ever
# arrived. With the ping gone, the first real request after a quiet
# period still pays a 30-60s cold start (image-baked weights, eager
# mode, AOT compile cache, deep-gemm warmup skip) but every subsequent
# request within the Modal scaledown window lands on a warm container
# for free. The 2-minute scaledown window is wide enough that a parent
# who reads the welcome screen and clicks a chip pays zero extra.
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _oauth_user(request: Request) -> dict:
if parse_huggingface_oauth is None:
return {"signed_in": False}
try:
info = parse_huggingface_oauth(request)
except Exception as e:
print(f"[auth] parse failed: {type(e).__name__}: {e}", flush=True)
return {"signed_in": False}
if info is None:
return {"signed_in": False}
user = getattr(info, "user_info", None)
username = getattr(user, "preferred_username", None) or getattr(user, "name", None) or getattr(user, "sub", None)
if not username:
return {"signed_in": False}
return {
"signed_in": True,
"username": str(username),
"display_name": str(getattr(user, "name", "") or username),
"avatar_url": str(getattr(user, "picture", "") or ""),
}
def _owner_from_request(request: Request, session_id: str) -> tuple[str, str | None, str]:
user = _oauth_user(request)
clean_session = (session_id or "").strip()[:80]
if not clean_session:
clean_session = str(uuid.uuid4())
if user.get("signed_in"):
return f"hf:{user['username']}", user["username"], clean_session
return f"anon:{clean_session}", None, clean_session
def _safe_slug(value: str) -> str:
cleaned = _SAFE_KEY.sub("-", value).strip("-")
return (cleaned or "anon")[:80]
def _history_path(owner_key: str) -> Path:
return DATA_DIR / f"user-{_safe_slug(owner_key)}.json"
def _empty_history() -> dict:
return {"messages": [], "profile": None, "updated_at": None}
def _read_history(owner_key: str) -> dict:
path = _history_path(owner_key)
if not path.exists():
return _empty_history()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
print(f"[history] read failed for {owner_key}: {type(e).__name__}: {e}", flush=True)
return _empty_history()
if not isinstance(data, dict):
return _empty_history()
data.setdefault("messages", [])
data.setdefault("profile", None)
return data
def _write_history(owner_key: str, data: dict) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
path = _history_path(owner_key)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
def _public_messages(messages: list) -> list:
return [
{
"role": m.get("role"),
"content": m.get("content", ""),
"age": m.get("age"),
"child_name": m.get("child_name") or "",
"tone": m.get("tone") or "",
"created_at": m.get("created_at", ""),
}
for m in messages[-_MAX_MESSAGES:]
]
def _sectioned_to_text(sectioned: str) -> str:
parts = (sectioned or "").split(SECTION_SEP)
labels = ["Opener", "Body", "Closer", "If they ask more"]
lines = []
for label, part in zip(labels, parts):
text = (part or "").strip()
if text:
lines.append(f"{label}: {text}")
return "\n\n".join(lines)
def _make_drafter(seed: int = 0):
from llm import FabellaVLLM
return FabellaVLLM(base_url=MODAL_DRAFTER_URL, model_name="gemma-4", seed=seed)
def _make_judge(seed: int = 0):
from llm import FabellaVLLM
return FabellaVLLM(
base_url=MODAL_JUDGE_URL,
model_name="nemotron-3-4b",
temperature=0.0,
top_p=1.0,
max_tokens=1024,
seed=seed,
)
# Sections joined with U+001F (Unit Separator) so the frontend can split
# reliably on a character that never appears in natural text.
SECTION_SEP = "\x1f"
def _make_explanation_sync(situation: str, age: int, child_name: str, tone: str, seed: int, history: list | None = None, owner_key: str = "", session_id: str = "", share_trace: bool = True) -> str:
clean_situation = sanitize_situation(situation)
clean_name = sanitize_name(child_name)
clean_tone = (tone or "gentle").strip().lower()
if clean_tone not in ("gentle", "matter-of-fact", "playful"):
clean_tone = "gentle"
if has_profanity(clean_situation) or has_profanity(clean_name):
return SECTION_SEP.join([
"Fabella (safety)",
"Some of the words used aren't allowed. Please try different words.",
"", "",
])
if not clean_situation:
return SECTION_SEP.join([
"Fabella (empty)",
"Tell me about the situation first - what's going on, in a sentence or two?",
"", "",
])
if not (5 <= int(age) <= 12):
age = 7 # default for out-of-range
clean_history = []
if isinstance(history, list):
for item in history[-6:]:
if not isinstance(item, dict):
continue
role = (item.get("role") or "").strip().lower()
content = (item.get("content") or "").strip()
if role in ("parent", "fabella") and content:
clean_history.append({"role": role, "content": content[:1200]})
# Merge in durable memory from the bucket so the drafter can use it.
if not owner_key:
owner_key = f"anon:{(session_id or 'anon')[:80]}"
mem = memory_layer.read_memory(owner_key)
mem_block = memory_layer.memory_context_block(mem)
if mem_block:
clean_history = [{"role": "memory", "content": mem_block}] + clean_history
req = ExplainRequest(
situation=clean_situation,
age=int(age),
child_name=clean_name,
tone=clean_tone,
seed=int(seed or 0),
history=clean_history,
share_trace=bool(share_trace),
)
try:
print(
f"[app] request: age={req.age} tone={req.tone} name='{req.child_name}' situation='{req.situation[:60]}…'",
flush=True,
)
drafter = _make_drafter(seed=req.seed)
judge = _make_judge(seed=req.seed)
result = run_agent(drafter, req, judge_llm=judge)
return SECTION_SEP.join([
result.get("opener", "") or "",
result.get("body", "") or "",
result.get("closer", "") or "",
result.get("followup", "") or "",
])
except Exception as e:
print(f"[app] handler error: {type(e).__name__}: {e}", flush=True)
return SECTION_SEP.join([
"Fabella (error)",
f"_Generation failed: {type(e).__name__}: {e}_",
"", "",
])
@app.api(name="make_explanation")
def make_explanation(
situation: str,
age: int,
child_name: str,
tone: str,
seed: int,
history: list | None = None,
owner_key: str = "",
session_id: str = "",
share_trace: bool = True,
) -> str:
"""Draft a short, kind, age-appropriate explanation.
Returns four sections joined by U+001F:
0: opener (one sentence the parent can say to start)
1: body (1-3 short paragraphs)
2: closer (one sentence the parent can say to land)
3: follow-up (optional one sentence if the child has another question)
`history` is a list of recent parent/Fabella turns so follow-up questions
in the same conversation can be answered with the previous explanation
in context. The drafter sees the last 6 turns. Long-term memory from
the bucket (summary, preferences, durable facts) is folded in
automatically when `owner_key` is provided.
`share_trace` (default True) opts the request into the public,
anonymized agent-trace dataset (see trace.py). Pass False to skip
publishing this request. The global kill switch is FABELLA_SHARE_TRACES=0.
"""
return _make_explanation_sync(
situation, age, child_name, tone, seed, history, owner_key, session_id, share_trace
)
def _clean_audio_text(text: str) -> str:
clean = " ".join((text or "").split())
if len(clean) > 1600:
clean = clean[:1600].rsplit(" ", 1)[0].strip()
return clean
def _narration_from_sections(opener: str, body: str, closer: str, followup: str) -> str:
"""Join the four sections into one plain conversational narration.
The TTS must never speak the structural labels ("Opener", "Body", "Closer",
"If they ask more"). It must never include a child's name. We address the
listener as "you" only.
"""
parts = []
for piece in (opener, body, closer, followup):
piece = (piece or "").strip()
if piece:
parts.append(piece)
return " ".join(parts)
def _strip_labeled_draft(text: str) -> str:
"""Fallback: if a client sends the raw labeled draft, strip the labels."""
if not text:
return ""
out = []
for line in text.splitlines():
stripped = re.sub(
r"^\s*(Opener|Body|Closer|If they ask more|If they ask another question)\s*:\s*",
"",
line,
flags=re.IGNORECASE,
)
if stripped.strip():
out.append(stripped.strip())
return " ".join(out).strip()
def _make_audio_sync(text: str, tone: str, opener: str = "", body: str = "", closer: str = "", followup: str = "") -> str:
if opener or body or closer or followup:
clean_text = _narration_from_sections(opener, body, closer, followup)
else:
clean_text = _strip_labeled_draft(text)
clean_text = _clean_audio_text(clean_text)
if not clean_text:
return "ERROR: Nothing to read aloud yet."
clean_tone = (tone or "gentle").strip().lower()
voice_by_tone = {
"gentle": "A calm adult woman with a soft, warm, reassuring voice, speaking slowly and clearly for a child.",
"matter-of-fact": "A calm adult woman with a clear, steady, practical voice, speaking plainly and kindly for a child.",
"playful": "A friendly adult woman with a lightly playful, bright, reassuring voice, speaking clearly for a child.",
}
payload = {
"text": clean_text,
"voice_description": voice_by_tone.get(clean_tone, voice_by_tone["gentle"]),
"cfg_value": 2.0,
"inference_timesteps": 10,
"normalize": True,
"denoise": True,
}
req = urllib.request.Request(
MODAL_TTS_URL.rstrip("/") + "/synthesize",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "audio/wav"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=180) as res:
audio = res.read()
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")[:300]
print(f"[app] tts HTTP error: {e.code}: {detail}", flush=True)
return f"ERROR: Read-aloud failed: HTTP {e.code}"
except Exception as e:
print(f"[app] tts error: {type(e).__name__}: {e}", flush=True)
return f"ERROR: Read-aloud failed: {type(e).__name__}: {e}"
if not audio:
return "ERROR: Read-aloud returned no audio."
return "data:audio/wav;base64," + base64.b64encode(audio).decode("ascii")
@app.api(name="make_audio")
def make_audio(text: str, tone: str, opener: str = "", body: str = "", closer: str = "", followup: str = "") -> str:
"""Synthesize a Fabella explanation as a base64 WAV data URL.
The frontend should pass the four sections explicitly so the server can
join them into a clean conversational narration. `text` is accepted as a
legacy fallback and its labels are stripped before TTS.
"""
return _make_audio_sync(text, tone, opener, body, closer, followup)
def _validate_audio_data_url(data_url: str) -> None:
if not data_url.startswith("data:audio/") or "," not in data_url:
raise ValueError("Expected a browser audio recording data URL.")
header, payload = data_url.split(",", 1)
if ";base64" not in header:
raise ValueError("Audio recording must be base64 encoded.")
# Validate and cap size before proxying. Browser MediaRecorder output is
# compact; 8 MB is plenty for a short parent voice note.
audio = base64.b64decode(payload, validate=True)
if len(audio) > 8 * 1024 * 1024:
raise ValueError("Voice note is too large. Please keep it under about 30 seconds.")
if len(audio) < 256:
raise ValueError("Voice note was empty.")
def _transcribe_audio_sync(data_url: str, target_lang: str = "en-US") -> str:
clean_lang = (target_lang or "en-US").strip()
if clean_lang != "auto" and not re.match(r"^[a-z]{2}-[A-Z]{2}$", clean_lang):
clean_lang = "en-US"
try:
_validate_audio_data_url(data_url)
except Exception as e:
return f"ERROR: {e}"
payload = json.dumps({"audio_data_url": data_url, "target_lang": clean_lang}).encode("utf-8")
req = urllib.request.Request(
MODAL_ASR_URL.rstrip("/") + "/transcribe",
data=payload,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=600) as res:
body = json.loads(res.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")[:300]
print(f"[asr] modal HTTP error: {e.code}: {detail}", flush=True)
return f"ERROR: Voice transcription failed: HTTP {e.code}"
except Exception as e:
print(f"[asr] modal error: {type(e).__name__}: {e}", flush=True)
return f"ERROR: Voice transcription failed: {type(e).__name__}: {e}"
if not body.get("ok"):
return f"ERROR: Voice transcription failed: {body.get('error') or 'unknown error'}"
text = str(body.get("text") or "").strip()
return text or "ERROR: Nemotron ASR returned an empty transcript."
@app.api(name="transcribe_audio")
def transcribe_audio(data_url: str, target_lang: str = "en-US") -> str:
"""Transcribe a short browser-recorded voice note via Modal T4 ASR.
The ASR runtime is deliberately isolated in ``modal_asr_app.py`` so NeMo,
ffmpeg, and CUDA dependencies cannot break the HF Space build.
"""
return _transcribe_audio_sync(data_url, target_lang)
@app.get("/api/me")
async def api_me(request: Request):
user = _oauth_user(request)
return JSONResponse(user)
@app.get("/api/history")
async def api_history(request: Request, session_id: str = ""):
DATA_DIR.mkdir(parents=True, exist_ok=True)
owner_key, _, session_id = _owner_from_request(request, session_id)
with _history_lock:
data = _read_history(owner_key)
return JSONResponse({
"session_id": session_id,
"profile": data.get("profile"),
"messages": _public_messages(data.get("messages", [])),
})
@app.post("/api/history/append")
async def api_history_append(request: Request):
DATA_DIR.mkdir(parents=True, exist_ok=True)
payload = await request.json()
session_id = str(payload.get("session_id") or "")
owner_key, _, session_id = _owner_from_request(request, session_id)
parent = sanitize_situation(payload.get("parent") or "")
fabella = _clean_audio_text(payload.get("fabella") or "")
if not parent and not fabella:
return JSONResponse({"ok": False, "error": "nothing to store"}, status_code=400)
try:
age = int(payload.get("age") or 0) or None
except Exception:
age = None
child_name = sanitize_name(payload.get("child_name") or "")
tone = (payload.get("tone") or "gentle").strip().lower()
if tone not in ("gentle", "matter-of-fact", "playful"):
tone = "gentle"
now = _now_iso()
with _history_lock:
data = _read_history(owner_key)
if parent:
data["messages"].append({
"role": "parent",
"content": parent,
"age": age,
"child_name": child_name,
"tone": tone,
"created_at": now,
})
if fabella:
data["messages"].append({
"role": "fabella",
"content": fabella,
"age": age,
"child_name": child_name,
"tone": tone,
"created_at": now,
})
data["messages"] = data["messages"][-_MAX_MESSAGES:]
profile = data.get("profile") or {}
profile.update({
"child_name": child_name or profile.get("child_name", ""),
"child_age": age or profile.get("child_age"),
"preferred_tone": tone or profile.get("preferred_tone", "gentle"),
"updated_at": now,
})
data["profile"] = profile
data["updated_at"] = now
_write_history(owner_key, data)
return JSONResponse({"ok": True, "session_id": session_id})
@app.post("/api/history/clear")
async def api_history_clear(request: Request):
DATA_DIR.mkdir(parents=True, exist_ok=True)
payload = await request.json()
session_id = str(payload.get("session_id") or "")
owner_key, _, session_id = _owner_from_request(request, session_id)
with _history_lock:
path = _history_path(owner_key)
if path.exists():
try:
path.unlink()
except Exception as e:
print(f"[history] clear failed for {owner_key}: {type(e).__name__}: {e}", flush=True)
return JSONResponse({"ok": True, "session_id": session_id})
@app.get("/api/history/download")
async def api_history_download(request: Request, session_id: str = ""):
"""Bundle the parent's full local data into a single JSON download.
The download contains every chat turn stored in the parent's
bucket-backed history file, the durable memory, and a small
"trace-publication" section that tells them:
* how many of their generations opted into the public trace dataset,
* which dataset the rows go to (or would go to, on capture failure),
* a copy of the anonymization rules so they can verify what was
and was not recorded.
Trace rows themselves are not in this file -- they live in the
public dataset and contain only the redacted/anonymized data, not
any of the raw material the parent typed. The download proves the
point: there is nothing in here that is not also in the user's own
browser, and the public rows cannot leak anything more.
The response is ``Content-Disposition: attachment`` so the frontend
can trigger a file save via a hidden ``<a download>``.
"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
owner_key, _, session_id = _owner_from_request(request, session_id)
with _history_lock:
history = _read_history(owner_key)
mem = memory_layer.read_memory(owner_key)
# Best-effort estimate of "how many turns went to the public trace
# dataset": the per-parent bucket does not store the trace
# submission receipts, so this is an upper bound (the number of
# turns that *had share_trace* at the time of generation, which
# the JS records as ``shared`` in the local message). If the
# client did not record that flag we fall back to total message
# count, which is a soft upper bound.
turn_count = len(history.get("messages") or [])
shared_count = sum(
1 for m in history.get("messages", []) if m.get("shared") is True
)
bundle = {
"schema": "fabella.history-bundle.v1",
"exported_at": _now_iso(),
"owner_key": owner_key,
"session_id": session_id,
"signed_in": not owner_key.startswith("anon:"),
"profile": history.get("profile"),
"messages": _public_messages(history.get("messages", [])),
"memory": memory_layer.public_view(mem),
"trace_publication": {
"dataset": "build-small-hackathon/fabella-traces",
"url": "https://huggingface.co/datasets/build-small-hackathon/fabella-traces",
"this_session_max_published_rows": shared_count,
"this_session_max_turns": turn_count,
"anonymization": [
"Child name is dropped from the request and replaced with [name] in the draft.",
"Raw situation text is never stored; only its SHA-256 hash, the first 60 chars, and its length are kept.",
"Freeform history turns are replaced with role + length counts in the published row.",
"The drafter's static system prompt is shipped in full (it's a public string in this repo).",
"Each row is its own data/<trace_id>.json file (race-free across Space replicas).",
],
},
}
body = json.dumps(bundle, ensure_ascii=False, indent=2).encode("utf-8")
filename = f"fabella-history-{session_id[:24] or 'anon'}.json"
return Response(
content=body,
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# --- Memory endpoints -------------------------------------------------------
@app.get("/api/memory")
async def api_memory(request: Request, session_id: str = ""):
owner_key, _, session_id = _owner_from_request(request, session_id)
mem = memory_layer.read_memory(owner_key)
return JSONResponse({
"session_id": session_id,
"memory": memory_layer.public_view(mem),
})
@app.post("/api/memory/append")
async def api_memory_append(request: Request):
payload = await request.json()
session_id = str(payload.get("session_id") or "")
owner_key, _, session_id = _owner_from_request(request, session_id)
parent = sanitize_situation(payload.get("parent") or "")
fabella = _clean_audio_text(payload.get("fabella") or "")
preferences = {}
if "child_name" in payload:
preferences["child_name"] = sanitize_name(payload.get("child_name") or "")
if "child_age" in payload:
try:
age = int(payload.get("child_age") or 0)
if 3 <= age <= 18:
preferences["child_age"] = age
except Exception:
pass
if "preferred_tone" in payload:
tone = (payload.get("preferred_tone") or "").strip().lower()
if tone in ("gentle", "matter-of-fact", "playful"):
preferences["preferred_tone"] = tone
if not parent and not fabella:
return JSONResponse({"ok": False, "error": "nothing to store"}, status_code=400)
res = memory_layer.append_turn(owner_key, parent, fabella, preferences=preferences)
return JSONResponse({
"ok": True,
"session_id": session_id,
"extraction": res.extraction,
"memory": memory_layer.public_view(res.memory),
})
@app.post("/api/memory/clear")
async def api_memory_clear(request: Request):
payload = await request.json()
session_id = str(payload.get("session_id") or "")
owner_key, _, session_id = _owner_from_request(request, session_id)
memory_layer.clear_memory(owner_key)
return JSONResponse({"ok": True, "session_id": session_id})
# --- HTML page --------------------------------------------------------------
TONE_CHOICES = [
("gentle", "Gentle"),
("matter-of-fact", "Matter-of-fact"),
("playful", "Playful"),
]
EXAMPLE_SITUATIONS = [
("My 7-year-old's grandma is in the hospital for surgery. She asked why grandma is there."),
("We're moving to a new house in 3 weeks. My kid is worried about leaving her friends."),
("My child's dog died yesterday. She keeps asking when the dog is coming back."),
("It's time to start sharing toys at preschool. My son refuses and has started hitting."),
("My 9-year-old wants a phone. All her friends have one and I said no."),
("There's a new baby coming in 4 months. My first grader is acting out and being mean to me."),
]
INDEX_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Fabella - small words for big questions</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
<script>
(function () {
try {
var allowed = ["storybook","bedtime","kitchen-table","garden","notebook","playroom"];
var saved = localStorage.getItem("fabella-theme");
var theme = allowed.indexOf(saved) >= 0 ? saved : "storybook";
document.documentElement.dataset.theme = theme;
} catch (_) {
document.documentElement.dataset.theme = "storybook";
}
})();
</script>
<style>
:root,
:root[data-theme="storybook"] {
--bg: #f7f4ec;
--bg-2: #efeae0;
--surface: #ffffff;
--surface-2: #f3efe6;
--line: #e2dccd;
--line-soft: #ebe6d8;
--text: #1f2330;
--text-soft: #3a3f4a;
--text-muted: #6b6f78;
--accent: #4f7a4a;
--accent-strong: #2e5a36;
--accent-soft: #dceadc;
--bubble-parent: #4f7a4a;
--bubble-parent-text: #ffffff;
--bubble-fabella: #ffffff;
--bubble-fabella-text: #1f2330;
--danger: #b04a3a;
--radius: 18px;
--radius-sm: 12px;
--pad-x: clamp(16px, 4vw, 40px);
--shadow: 0 1px 0 rgba(0,0,0,0.02), 0 12px 30px -22px rgba(20,30,20,0.18);
--ease: cubic-bezier(.2,.7,.2,1);
--font-sans: "Outfit", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
color-scheme: light;
}
:root[data-theme="bedtime"] {
--bg: #0f1320;
--bg-2: #141a2b;
--surface: #1a2138;
--surface-2: #202a45;
--line: #2a3354;
--line-soft: #232b46;
--text: #e6e8f2;
--text-soft: #c2c7d8;
--text-muted: #8b91aa;
--accent: #9aa9e8;
--accent-strong: #c0cbf5;
--accent-soft: #2a335a;
--bubble-parent: #3a4a8a;
--bubble-parent-text: #f3f4ff;
--bubble-fabella: #1c2440;
--bubble-fabella-text: #e6e8f2;
--danger: #d18a8a;
--shadow: 0 1px 0 rgba(0,0,0,0.3), 0 12px 30px -22px rgba(0,0,0,0.5);
color-scheme: dark;
}
:root[data-theme="kitchen-table"] {
--bg: #faf3e3;
--bg-2: #f3e9c9;
--surface: #fff8e6;
--surface-2: #f7ecce;
--line: #d9c79a;
--line-soft: #e6d8b0;
--text: #3a2a18;
--text-soft: #574231;
--text-muted: #8a7556;
--accent: #b07a2a;
--accent-strong: #8a5a18;
--accent-soft: #f0d9a5;
--bubble-parent: #a86a1c;
--bubble-parent-text: #fff8e6;
--bubble-fabella: #fff8e6;
--bubble-fabella-text: #3a2a18;
--danger: #a14a2a;
--shadow: 0 1px 0 rgba(0,0,0,0.02), 0 12px 30px -22px rgba(120,80,20,0.22);
color-scheme: light;
}
:root[data-theme="garden"] {
--bg: #f1f5e8;
--bg-2: #e6efd4;
--surface: #fbfaf0;
--surface-2: #eef3dc;
--line: #cfdcb5;
--line-soft: #dde8c4;
--text: #1f2a14;
--text-soft: #3a4a26;
--text-muted: #6e7d4d;
--accent: #6b8a3a;
--accent-strong: #4a6a1c;
--accent-soft: #dde8c4;
--bubble-parent: #6b8a3a;
--bubble-parent-text: #fbfaf0;
--bubble-fabella: #fbfaf0;
--bubble-fabella-text: #1f2a14;
--danger: #a14a2a;
--shadow: 0 1px 0 rgba(0,0,0,0.02), 0 12px 30px -22px rgba(60,80,20,0.18);
color-scheme: light;
}
:root[data-theme="notebook"] {
--bg: #f6f4ee;
--bg-2: #ece8dd;
--surface: #fdfcf7;
--surface-2: #efece2;
--line: #d4cebd;
--line-soft: #e1ddce;
--text: #20242e;
--text-soft: #3b414c;
--text-muted: #6a6f7a;
--accent: #2a3a52;
--accent-strong: #1a2a42;
--accent-soft: #d8deeb;
--bubble-parent: #2a3a52;
--bubble-parent-text: #fdfcf7;
--bubble-fabella: #fdfcf7;
--bubble-fabella-text: #20242e;
--danger: #a23a3a;
--shadow: 0 1px 0 rgba(0,0,0,0.03), 0 12px 30px -22px rgba(20,30,50,0.18);
color-scheme: light;
}
:root[data-theme="playroom"] {
--bg: #fdf2f4;
--bg-2: #fbe5ea;
--surface: #ffffff;
--surface-2: #fdeef2;
--line: #f1d4dc;
--line-soft: #f9e3e9;
--text: #3a2030;
--text-soft: #5a3a4a;
--text-muted: #8a6a78;
--accent: #e07aa0;
--accent-strong: #b8507a;
--accent-soft: #fbd6e3;
--bubble-parent: #e07aa0;
--bubble-parent-text: #ffffff;
--bubble-fabella: #ffffff;
--bubble-fabella-text: #3a2030;
--danger: #c44a4a;
--shadow: 0 1px 0 rgba(0,0,0,0.02), 0 12px 30px -22px rgba(180,80,120,0.18);
color-scheme: light;
}
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 6px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; }
body { min-height: 100vh; }
a { color: var(--accent-strong); }
.appbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px var(--pad-x);
background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: saturate(140%) blur(8px);
border-bottom: 1px solid var(--line-soft);
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; letter-spacing: -0.01em; }
.brand-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
.brand small { color: var(--text-muted); font-weight: 500; margin-left: 6px; }
.appbar-actions { display: flex; align-items: center; gap: 8px; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
font: 500 12px/1 var(--font-sans);
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text-soft);
cursor: pointer;
}
.chip.is-active { border-color: var(--accent); color: var(--accent-strong); background: var(--accent-soft); }
.chip-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
.chip-dot.is-on { background: var(--accent); }
.btn-ghost {
border: 1px solid var(--line);
background: var(--surface);
color: var(--text-soft);
border-radius: 999px;
padding: 8px 14px;
font: 500 13px var(--font-sans);
cursor: pointer;
}
.btn-ghost:hover { color: var(--text); border-color: var(--accent); }
.thread {
max-width: 760px;
margin: 0 auto;
padding: clamp(28px, 4vw, 48px) var(--pad-x) 200px;
display: flex;
flex-direction: column;
gap: 22px;
}
.welcome { margin-top: 6vh; }
.welcome h1 {
font-size: clamp(32px, 5vw, 44px);
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0 0 10px;
font-weight: 700;
}
.welcome h1 em { font-style: normal; color: var(--accent-strong); }
.welcome p { color: var(--text-soft); margin: 0 0 22px; max-width: 56ch; }
.examples { display: flex; flex-direction: column; gap: 8px; max-width: 56ch; }
.example-btn {
text-align: left;
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
border-radius: var(--radius-sm);
padding: 12px 14px;
font: 500 14px var(--font-sans);
cursor: pointer;
}
.example-btn:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent-soft) 40%, var(--surface)); }
.turn { display: flex; gap: 12px; align-items: flex-start; }
.turn.user { flex-direction: row-reverse; }
.avatar {
width: 32px; height: 32px;
flex: 0 0 32px;
border-radius: 50%;
background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
font: 600 12px var(--font-sans);
color: var(--text-muted);
border: 1px solid var(--line-soft);
}
.avatar.fabella { background: var(--accent-soft); color: var(--accent-strong); border-color: transparent; }
.avatar.user { background: var(--bubble-parent); color: var(--bubble-parent-text); border-color: transparent; }
.bubble {
max-width: min(72ch, calc(100% - 56px));
padding: 14px 16px;
border-radius: 18px;
background: var(--bubble-fabella);
color: var(--bubble-fabella-text);
border: 1px solid var(--line);
line-height: 1.5;
font-size: 15.5px;
box-shadow: var(--shadow);
}
.turn.user .bubble {
background: var(--bubble-parent);
color: var(--bubble-parent-text);
border-color: transparent;
border-bottom-right-radius: 6px;
}
.turn.fabella .bubble { border-bottom-left-radius: 6px; }
.bubble p { margin: 0 0 8px; }
.bubble p:last-child { margin-bottom: 0; }
.section-heading {
margin: 14px 0 4px;
font: 600 12.5px var(--font-sans);
color: var(--text-muted);
letter-spacing: 0.02em;
text-transform: none;
}
.section-heading:first-child { margin-top: 0; }
.section-text { white-space: pre-wrap; }
.turn-actions {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.btn-read {
border: 1px solid var(--accent);
background: var(--surface);
color: var(--accent-strong);
border-radius: 999px;
padding: 6px 12px;
font: 500 12.5px var(--font-sans);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-read:hover { background: var(--accent-soft); }
.btn-read[disabled] { opacity: 0.6; cursor: progress; }
.btn-read .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.audio-inline { width: 100%; margin-top: 8px; display: none; }
.audio-inline.is-on { display: block; }
.audio-status { font: 500 11px var(--font-mono); color: var(--text-muted); margin-top: 4px; letter-spacing: 0.04em; }
.composer {
position: fixed;
left: 0; right: 0; bottom: 0;
padding: 14px var(--pad-x) 22px;
background: linear-gradient(180deg, transparent, var(--bg) 24px);
z-index: 4;
}
.composer-inner {
max-width: 760px;
margin: 0 auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: 0 24px 60px -28px rgba(20,30,20,0.18), 0 2px 0 rgba(0,0,0,0.02);
padding: 10px 12px 10px 16px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: end;
}
.composer textarea {
width: 100%;
resize: none;
border: 0;
outline: 0;
background: transparent;
font: 400 15px var(--font-sans);
color: var(--text);
padding: 8px 0;
min-height: 28px;
max-height: 200px;
}
.composer textarea::placeholder { color: var(--text-muted); }
.composer-controls { display: flex; align-items: center; gap: 8px; }
.btn-record {
border: 1px solid var(--line);
background: var(--bg);
color: var(--text-soft);
border-radius: 999px;
padding: 10px 12px;
font: 600 13px var(--font-sans);
cursor: pointer;
white-space: nowrap;
}
.btn-record:hover:not([disabled]) { border-color: var(--accent); color: var(--accent-strong); }
.btn-record.is-recording { border-color: var(--danger); color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--surface)); }
.btn-record[disabled] { opacity: 0.55; cursor: not-allowed; }
.btn-send {
border: 0;
background: var(--accent);
color: #ffffff;
border-radius: 999px;
padding: 10px 18px;
font: 600 14px var(--font-sans);
cursor: pointer;
display: inline-flex; align-items: center; gap: 8px;
}
.btn-send[disabled] { opacity: 0.55; cursor: not-allowed; }
.btn-send:hover:not([disabled]) { background: var(--accent-strong); }
.composer-hint {
font: 500 11px var(--font-mono);
color: var(--text-muted);
letter-spacing: 0.06em;
text-align: center;
margin-top: 8px;
}
.asr-status {
max-width: 760px;
margin: 6px auto 0;
font: 500 11px var(--font-mono);
color: var(--text-muted);
letter-spacing: 0.04em;
text-align: left;
}
.typing {
display: inline-flex; align-items: center; gap: 6px;
font: 500 13px var(--font-sans); color: var(--text-muted);
}
.typing-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); animation: bounce 1.2s infinite; }
.typing-dot:nth-child(2) { animation-delay: 0.15s; }
.typing-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-4px); opacity: 1; }
}
.error {
border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent);
color: var(--danger);
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
border-radius: var(--radius-sm);
padding: 10px 12px;
font: 500 13px var(--font-sans);
}
.foot {
text-align: center;
font: 500 11px var(--font-mono);
color: var(--text-muted);
letter-spacing: 0.08em;
padding: 0 0 8px;
text-transform: uppercase;
}
.foot a { color: var(--text-muted); text-decoration: none; border-bottom: 1px dotted var(--line); }
.foot a:hover { color: var(--text); }
</style>
</head>
<body>
<header class="appbar" role="banner">
<div class="brand">
<span class="brand-dot" aria-hidden="true"></span>
<span>Fabella <small>small words for big questions</small></span>
</div>
<div class="appbar-actions">
<button type="button" class="chip" id="age-chip"><span class="chip-dot" aria-hidden="true"></span>Age <span id="age-chip-val">7</span></button>
<button type="button" class="chip" id="tone-chip"><span class="chip-dot" aria-hidden="true"></span>Tone <span id="tone-chip-val">gentle</span></button>
<button type="button" class="btn-ghost" id="open-settings">Settings</button>
<button type="button" class="btn-ghost" id="topbar-clear-history">Clear</button>
</div>
</header>
<main id="thread" class="thread" aria-live="polite"></main>
<section class="composer" aria-label="Composer">
<form id="composer" class="composer-inner" novalidate>
<textarea id="input" rows="1" placeholder="Describe the hard situation. A sentence or two is enough." maxlength="800" required></textarea>
<div class="composer-controls">
<button type="button" class="btn-record" id="record-btn" title="Record a short voice note">Record</button>
<button type="submit" class="btn-send" id="send-btn"><span id="send-label">Draft</span><span aria-hidden="true">&rarr;</span></button>
</div>
</form>
<div class="asr-status" id="asr-status"></div>
<div class="composer-hint">Fabella checks every draft against a six-criterion rubric. Record uses Nemotron 3.5 ASR on demand.</div>
</section>
<dialog id="settings" style="border:1px solid var(--line); border-radius: var(--radius); padding: 0; max-width: 480px; width: calc(100% - 32px); background: var(--surface); color: var(--text);">
<form method="dialog" style="padding: 22px;">
<h2 style="margin: 0 0 12px; font-size: 18px;">Settings</h2>
<label style="display:block; font: 500 12px var(--font-mono); color: var(--text-muted); margin: 12px 0 6px; letter-spacing: 0.06em; text-transform: uppercase;">Child's age</label>
<input id="age-range" type="range" min="5" max="12" step="1" value="7" style="width:100%;">
<div style="font: 500 12px var(--font-sans); color: var(--text-soft); margin-top: 4px;"><span id="age-readout">7</span> years</div>
<label style="display:block; font: 500 12px var(--font-mono); color: var(--text-muted); margin: 16px 0 6px; letter-spacing: 0.06em; text-transform: uppercase;">Child's name (optional)</label>
<input id="child-name" type="text" maxlength="30" placeholder="leave empty to address the parent" style="width:100%; padding: 10px 12px; border:1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--text); font: 400 14px var(--font-sans);">
<label style="display:block; font: 500 12px var(--font-mono); color: var(--text-muted); margin: 16px 0 6px; letter-spacing: 0.06em; text-transform: uppercase;">Tone</label>
<div id="tone-row" style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px;"></div>
<label style="display:block; font: 500 12px var(--font-mono); color: var(--text-muted); margin: 16px 0 6px; letter-spacing: 0.06em; text-transform: uppercase;">Theme</label>
<select id="theme-select" style="width:100%; padding: 10px 12px; border:1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--text); font: 500 14px var(--font-sans);">
<option value="storybook">Storybook (default)</option>
<option value="bedtime">Bedtime (soft dark)</option>
<option value="kitchen-table">Kitchen Table (warm cream)</option>
<option value="garden">Garden (gentle greens)</option>
<option value="notebook">Notebook (paper and ink)</option>
<option value="playroom">Playroom (soft pastels)</option>
</select>
<div style="display:flex; gap: 8px; justify-content: flex-end; margin-top: 18px;">
<button type="button" class="btn-ghost" id="settings-cancel">Close</button>
<button type="submit" class="btn-send" style="padding: 8px 14px;">Save</button>
</div>
</form>
<hr style="border: 0; border-top: 1px solid var(--line); margin: 18px 0 0;">
<div style="padding: 18px 22px 22px;">
<h2 style="margin: 0 0 6px; font-size: 16px;">Your data, on this device</h2>
<p style="margin: 0 0 12px; font: 400 13px/1.4 var(--font-sans); color: var(--text-soft);">
Your chat history and memory are stored in this Space's bucket, keyed to you.
When you opt in, redacted copies of the drafter/judge trace are published to a
<a href="https://huggingface.co/datasets/build-small-hackathon/fabella-traces" target="_blank" rel="noopener">public dataset</a>
— they contain no raw situation text, no child name, and no trace that links back to you.
</p>
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
<button type="button" class="btn-ghost" id="download-history">Download my history</button>
<button type="button" class="btn-ghost" id="settings-clear-history">Clear history</button>
</div>
</div>
</dialog>
<footer class="foot">
Built for the <a href="https://huggingface.co/spaces/build-small-hackathon/README" target="_blank" rel="noopener">Build Small Hackathon</a>
</footer>
<script>
(function () {
"use strict";
var TONE_CHOICES = __TONE_CHOICES__;
var EXAMPLES = __EXAMPLES__;
var SECTION_SEP = "\x1f";
var SESSION_KEY = "fabella_session_id";
var THEME_KEY = "fabella-theme";
var THEME_ALLOW = ["storybook","bedtime","kitchen-table","garden","notebook","playroom"];
function readTheme() {
try {
var saved = localStorage.getItem(THEME_KEY);
return THEME_ALLOW.indexOf(saved) >= 0 ? saved : "storybook";
} catch (_) {
return "storybook";
}
}
function applyTheme(name) {
var theme = THEME_ALLOW.indexOf(name) >= 0 ? name : "storybook";
document.documentElement.dataset.theme = theme;
var sel = document.getElementById("theme-select");
if (sel) sel.value = theme;
}
var currentTheme = readTheme();
applyTheme(currentTheme);
var sessionId = (function () {
try {
var existing = localStorage.getItem(SESSION_KEY);
if (!existing) {
existing = (crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + "-" + Math.random().toString(16).slice(2);
localStorage.setItem(SESSION_KEY, existing);
}
return existing;
} catch (_) {
return String(Date.now()) + "-" + Math.random().toString(16).slice(2);
}
})();
var thread = document.getElementById("thread");
var input = document.getElementById("input");
var recordBtn = document.getElementById("record-btn");
var asrStatus = document.getElementById("asr-status");
var sendBtn = document.getElementById("send-btn");
var sendLabel = document.getElementById("send-label");
var ageChip = document.getElementById("age-chip");
var ageChipVal = document.getElementById("age-chip-val");
var toneChip = document.getElementById("tone-chip");
var toneChipVal = document.getElementById("tone-chip-val");
var clearBtn = document.getElementById("topbar-clear-history");
var dlg = document.getElementById("settings");
var ageRange = document.getElementById("age-range");
var ageReadout = document.getElementById("age-readout");
var childName = document.getElementById("child-name");
var toneRow = document.getElementById("tone-row");
var settingsCancel = document.getElementById("settings-cancel");
var openSettings = document.getElementById("open-settings");
var themeSelect = document.getElementById("theme-select");
if (themeSelect) themeSelect.value = currentTheme;
var currentAge = 7;
var currentTone = "gentle";
var childNameValue = "";
var mediaRecorder = null;
var recordTimer = null;
var recordChunks = [];
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, function (c) { return ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]; });
}
function autosize() {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 200) + "px";
}
input.addEventListener("input", autosize);
function syncChips() {
ageChipVal.textContent = String(currentAge);
toneChipVal.textContent = currentTone;
}
function buildTones() {
toneRow.innerHTML = "";
TONE_CHOICES.forEach(function (pair) {
var val = pair[0];
var label = pair[1];
var id = "tone-" + val;
var wrap = document.createElement("label");
wrap.htmlFor = id;
wrap.style.cssText = "display:flex; align-items:center; justify-content:center; padding:10px; border:1px solid var(--line); border-radius: 10px; cursor:pointer; font: 500 13px var(--font-sans); color: var(--text-soft); background: var(--bg);";
wrap.dataset.tone = val;
var r = document.createElement("input");
r.type = "radio"; r.name = "tone"; r.id = id; r.value = val; r.style.display = "none";
r.checked = (val === currentTone);
if (r.checked) wrap.style.cssText += " border-color: var(--accent); color: var(--accent-strong); background: var(--accent-soft);";
r.addEventListener("change", function () {
Array.from(toneRow.children).forEach(function (c) { c.style.cssText = "display:flex; align-items:center; justify-content:center; padding:10px; border:1px solid var(--line); border-radius: 10px; cursor:pointer; font: 500 13px var(--font-sans); color: var(--text-soft); background: var(--bg);"; });
wrap.style.cssText += " border-color: var(--accent); color: var(--accent-strong); background: var(--accent-soft);";
currentTone = val;
syncChips();
});
wrap.appendChild(r);
wrap.appendChild(document.createTextNode(label));
toneRow.appendChild(wrap);
});
}
function welcomeOrThread(messages) {
thread.innerHTML = "";
if (!messages || !messages.length) {
var w = document.createElement("section");
w.className = "welcome";
w.innerHTML =
'<h1>Tell me the <em>hard thing</em>.</h1>' +
'<p>Fabella drafts a short, kind, age-appropriate explanation. A second small model checks it. You can read it aloud with one tap.</p>' +
'<div class="examples" id="examples"></div>';
thread.appendChild(w);
var ex = document.getElementById("examples");
EXAMPLES.forEach(function (s) {
var b = document.createElement("button");
b.type = "button";
b.className = "example-btn";
b.textContent = s;
b.addEventListener("click", function () {
input.value = s;
autosize();
input.focus();
});
ex.appendChild(b);
});
return;
}
messages.forEach(function (m) {
if (m.role === "parent") {
thread.appendChild(renderUserTurn(m.content));
} else {
thread.appendChild(renderFabellaTurn({ body: m.content }, null));
}
});
}
function renderUserTurn(text) {
var wrap = document.createElement("article");
wrap.className = "turn user";
wrap.innerHTML =
'<div class="avatar user" aria-hidden="true">You</div>' +
'<div class="bubble"><div class="text">' + escapeHTML(text).replace(/\n/g, "<br>") + '</div></div>';
return wrap;
}
function parseFabellaText(text) {
var out = [];
var re = /(Opener|Body|Closer|If they ask more):\s*([\s\S]*?)(?=(?:\n\s*\n)(?:Opener|Body|Closer|If they ask more):|$)/g;
var m;
while ((m = re.exec(text)) !== null) {
out.push({ label: m[1], text: m[2].trim() });
}
return out;
}
function renderFabellaTurn(sections, audioUrl) {
var wrap = document.createElement("article");
wrap.className = "turn fabella";
var opener = (sections && sections.opener) || "";
var body = (sections && sections.body) || "";
var closer = (sections && sections.closer) || "";
var followup = (sections && sections.followup) || "";
function para(text) {
return '<p>' + escapeHTML(text).replace(/\n/g, "<br>") + '</p>';
}
var sectionsHTML =
(opener ? '<h3 class="section-heading">Where to begin</h3>' + para(opener) : "") +
(body ? '<h3 class="section-heading">The explanation</h3>' + body.split(/\n\s*\n/).filter(Boolean).map(function (p) { return para(p.trim()); }).join("") : "") +
(closer ? '<h3 class="section-heading">How to land it</h3>' + para(closer) : "") +
(followup ? '<h3 class="section-heading">If they ask another question</h3>' + para(followup) : "");
if (!sectionsHTML) {
sectionsHTML = '<div class="section-text">' + escapeHTML(String(sections || "")) + '</div>';
}
wrap.innerHTML =
'<div class="avatar fabella" aria-hidden="true">F</div>' +
'<div class="bubble">' + sectionsHTML +
'<div class="turn-actions">' +
'<button type="button" class="btn-read" data-action="read"><span class="dot"></span>Read aloud</button>' +
'</div>' +
'<audio class="audio-inline" controls></audio>' +
'<div class="audio-status" data-status></div>' +
'</div>';
var readBtn = wrap.querySelector('[data-action="read"]');
var audio = wrap.querySelector("audio.audio-inline");
var status = wrap.querySelector("[data-status]");
if (audioUrl) {
audio.src = audioUrl;
audio.classList.add("is-on");
}
readBtn.addEventListener("click", async function () {
if (readBtn.dataset.busy === "1") return;
readBtn.dataset.busy = "1";
readBtn.disabled = true;
status.textContent = "Warming VoxCPM2 and preparing narration...";
try {
var url = await readGradioString("make_audio", [
"",
currentTone,
opener,
body,
closer,
followup,
]);
if (url.startsWith("ERROR:")) throw new Error(url.slice(6).trim());
audio.src = url;
audio.classList.add("is-on");
status.textContent = "Ready. Press play.";
await audio.play().catch(function () {});
} catch (err) {
status.textContent = String(err.message || err);
} finally {
readBtn.disabled = false;
readBtn.dataset.busy = "0";
}
});
wrap.dataset.sections = JSON.stringify({ opener: opener, body: body, closer: closer, followup: followup });
return wrap;
}
function renderTyping() {
var wrap = document.createElement("article");
wrap.className = "turn fabella";
wrap.dataset.role = "typing";
wrap.innerHTML =
'<div class="avatar fabella" aria-hidden="true">F</div>' +
'<div class="bubble"><div class="typing"><span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span><span style="margin-left:6px;">Fabella is drafting, then checking.</span></div></div>';
return wrap;
}
function renderError(text) {
var wrap = document.createElement("article");
wrap.className = "turn fabella";
wrap.innerHTML =
'<div class="avatar fabella" aria-hidden="true">F</div>' +
'<div class="bubble"><div class="error">' + escapeHTML(text) + '</div></div>';
return wrap;
}
function scrollToEnd() {
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
function setBusy(busy) {
sendBtn.disabled = busy;
input.disabled = busy;
if (recordBtn && !recordBtn.classList.contains("is-recording")) recordBtn.disabled = busy;
sendLabel.textContent = busy ? "Drafting..." : "Draft";
}
function setAsrStatus(text) {
if (asrStatus) asrStatus.textContent = text || "";
}
function blobToDataUrl(blob) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () { resolve(String(reader.result || "")); };
reader.onerror = function () { reject(reader.error || new Error("Could not read recording")); };
reader.readAsDataURL(blob);
});
}
async function transcribeBlob(blob) {
setAsrStatus("Transcribing with Nemotron 3.5 ASR. First run can take a minute...");
var dataUrl = await blobToDataUrl(blob);
var text = await readGradioString("transcribe_audio", [dataUrl, "en-US"]);
if (text.startsWith("ERROR:")) throw new Error(text.slice(6).trim());
input.value = text;
autosize();
input.focus();
setAsrStatus("Transcript inserted. Edit it before drafting if needed.");
}
async function startRecording() {
if (!navigator.mediaDevices || !window.MediaRecorder) {
setAsrStatus("This browser does not support voice recording.");
return;
}
try {
var stream = await navigator.mediaDevices.getUserMedia({ audio: true });
recordChunks = [];
var recorderOptions = MediaRecorder.isTypeSupported("audio/webm") ? { mimeType: "audio/webm" } : undefined;
mediaRecorder = new MediaRecorder(stream, recorderOptions);
mediaRecorder.ondataavailable = function (event) {
if (event.data && event.data.size > 0) recordChunks.push(event.data);
};
mediaRecorder.onstop = async function () {
stream.getTracks().forEach(function (track) { track.stop(); });
recordBtn.classList.remove("is-recording");
recordBtn.textContent = "Record";
recordBtn.disabled = true;
clearTimeout(recordTimer);
try {
var blob = new Blob(recordChunks, { type: mediaRecorder.mimeType || "audio/webm" });
await transcribeBlob(blob);
} catch (err) {
setAsrStatus(String(err.message || err));
} finally {
recordBtn.disabled = false;
mediaRecorder = null;
}
};
mediaRecorder.start();
recordBtn.classList.add("is-recording");
recordBtn.textContent = "Stop";
setAsrStatus("Recording. Keep it short; auto-stops at 30 seconds.");
recordTimer = setTimeout(function () {
if (mediaRecorder && mediaRecorder.state === "recording") mediaRecorder.stop();
}, 30000);
} catch (err) {
setAsrStatus("Microphone unavailable: " + String(err.message || err));
}
}
if (recordBtn) {
recordBtn.addEventListener("click", function () {
if (mediaRecorder && mediaRecorder.state === "recording") {
setAsrStatus("Preparing transcript...");
mediaRecorder.stop();
return;
}
startRecording();
});
}
function _extractSseText(obj) {
if (obj == null) return null;
if (Array.isArray(obj)) return obj.find(function (v) { return typeof v === "string"; }) || null;
if (obj.msg === "process_completed" && obj.success) {
var out = obj.output && obj.output.data;
if (Array.isArray(out)) return out.find(function (v) { return typeof v === "string"; }) || null;
if (typeof out === "string") return out;
}
return null;
}
async function readGradioString(apiName, data) {
var res = await fetch("/gradio_api/call/" + apiName, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: data }),
});
if (!res.ok) {
var t = await res.text();
throw new Error("HTTP " + res.status + ": " + t.slice(0, 200));
}
var evt0 = await res.json();
var eventId = evt0.event_id;
var evt = await fetch("/gradio_api/call/" + apiName + "/" + eventId, { headers: { Accept: "text/event-stream" } });
if (!evt.ok || !evt.body) throw new Error("SSE open failed");
var reader = evt.body.getReader();
var dec = new TextDecoder();
var buf = "";
var result = null;
var err = null;
while (true) {
var r = await reader.read();
if (r.done) break;
buf += dec.decode(r.value, { stream: true });
var idx;
while ((idx = buf.indexOf("\n\n")) !== -1) {
var frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
var lines = frame.split("\n");
var line = null;
for (var li = 0; li < lines.length; li++) { if (lines[li].startsWith("data: ")) { line = lines[li]; break; } }
if (!line) continue;
var payload = line.slice(6).trim();
if (payload === "null" || payload === "") continue;
try {
var obj = JSON.parse(payload);
var text = _extractSseText(obj);
if (text) { result = text; break; }
if (obj && obj.msg === "process_completed" && obj.success === false) {
err = (obj.output && obj.output.error) || "Request failed";
break;
}
} catch (_) {}
}
if (result !== null || err) break;
}
if (err) throw new Error(err);
if (!result) throw new Error("No result");
return result;
}
var conversation = [];
async function callMakeExplanation(seed, situationText) {
var res = await fetch("/gradio_api/call/make_explanation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: [situationText, currentAge, childNameValue, currentTone, seed, conversation.slice(-6), "", sessionId, true] }),
});
if (!res.ok) {
var t = await res.text();
throw new Error("HTTP " + res.status + ": " + t.slice(0, 200));
}
var evt0 = await res.json();
var eventId = evt0.event_id;
var evt = await fetch("/gradio_api/call/make_explanation/" + eventId, { headers: { Accept: "text/event-stream" } });
if (!evt.ok || !evt.body) throw new Error("SSE open failed");
var reader = evt.body.getReader();
var dec = new TextDecoder();
var buf = "";
var result = null;
var err = null;
while (true) {
var r = await reader.read();
if (r.done) break;
buf += dec.decode(r.value, { stream: true });
var idx;
while ((idx = buf.indexOf("\n\n")) !== -1) {
var frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
var lines = frame.split("\n");
var line = null;
for (var li = 0; li < lines.length; li++) { if (lines[li].startsWith("data: ")) { line = lines[li]; break; } }
if (!line) continue;
var payload = line.slice(6).trim();
if (payload === "null" || payload === "") continue;
try {
var obj = JSON.parse(payload);
var text = _extractSseText(obj);
if (text) {
result = text.split(SECTION_SEP);
while (result.length < 4) result.push("");
break;
}
if (obj && obj.msg === "process_completed" && obj.success === false) {
err = (obj.output && obj.output.error) || "Generation failed";
break;
}
} catch (_) {}
}
if (result !== null || err) break;
}
if (err) throw new Error(err);
if (!result) throw new Error("No result");
return result;
}
function narrationFromSections(sectionByLabel) {
var parts = [];
["Opener", "Body", "Closer", "If they ask more"].forEach(function (k) {
var v = (sectionByLabel && sectionByLabel[k]) ? sectionByLabel[k].trim() : "";
if (v) parts.push(v);
});
return parts.join(" ");
}
function narrationFromSections(s) {
var parts = [];
["opener", "body", "closer", "followup"].forEach(function (k) {
var v = (s && s[k]) ? String(s[k]).trim() : "";
if (v) parts.push(v);
});
return parts.join(" ");
}
async function saveTurn(parentText, structured) {
var fabellaText = narrationFromSections(structured);
try {
await fetch("/api/history/append", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
parent: parentText,
fabella: fabellaText,
age: currentAge,
child_name: childNameValue,
tone: currentTone,
}),
});
} catch (_) {}
}
async function saveMemory(parentText, fabellaText) {
try {
await fetch("/api/memory/append", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
parent: parentText,
fabella: fabellaText,
child_name: childNameValue,
child_age: currentAge,
preferred_tone: currentTone,
}),
});
} catch (_) {}
}
async function loadHistory() {
try {
var r = await fetch("/api/history?session_id=" + encodeURIComponent(sessionId));
if (!r.ok) return null;
return await r.json();
} catch (_) { return null; }
}
async function init() {
buildTones();
syncChips();
var data = await loadHistory();
if (data && data.profile) {
if (data.profile.child_name) childNameValue = data.profile.child_name;
if (data.profile.child_age) currentAge = data.profile.child_age;
if (data.profile.preferred_tone) currentTone = data.profile.preferred_tone;
}
childName.value = childNameValue;
ageRange.value = String(currentAge);
ageReadout.textContent = String(currentAge);
syncChips();
welcomeOrThread(data && data.messages ? data.messages : []);
}
ageRange.addEventListener("input", function () {
ageReadout.textContent = ageRange.value;
});
dlg.addEventListener("close", function () {
currentAge = parseInt(ageRange.value, 10) || 7;
childNameValue = childName.value.trim();
var checked = dlg.querySelector("input[name='tone']:checked");
if (checked) currentTone = checked.value;
syncChips();
});
openSettings.addEventListener("click", function () { dlg.showModal(); });
settingsCancel.addEventListener("click", function () { dlg.close(); });
if (themeSelect) {
themeSelect.addEventListener("change", function () {
var next = THEME_ALLOW.indexOf(themeSelect.value) >= 0 ? themeSelect.value : "storybook";
currentTheme = next;
try { localStorage.setItem(THEME_KEY, next); } catch (_) {}
applyTheme(next);
});
}
clearBtn.addEventListener("click", async function () {
if (!confirm("Clear this conversation and long-term memory? This cannot be undone.")) return;
try {
await fetch("/api/history/clear", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
await fetch("/api/memory/clear", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
} catch (_) {}
conversation = [];
welcomeOrThread([]);
});
// The settings-dialog equivalents. Pull the download bundle from the
// server and trigger a file save via an off-DOM <a download>. The
// server decides the filename.
var downloadBtn = document.getElementById("download-history");
if (downloadBtn) {
downloadBtn.addEventListener("click", function () {
var url = "/api/history/download?session_id=" + encodeURIComponent(sessionId);
window.location.href = url;
});
}
var settingsClearBtn = document.getElementById("settings-clear-history");
if (settingsClearBtn) {
settingsClearBtn.addEventListener("click", async function () {
if (!confirm("Clear this conversation and long-term memory? This cannot be undone.")) return;
try {
await fetch("/api/history/clear", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
await fetch("/api/memory/clear", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
} catch (_) {}
conversation = [];
welcomeOrThread([]);
});
}
ageChip.addEventListener("click", function () { dlg.showModal(); });
toneChip.addEventListener("click", function () { dlg.showModal(); });
var seed = 0;
var form = document.getElementById("composer");
form.addEventListener("submit", async function (e) {
e.preventDefault();
var text = input.value.trim();
if (!text) { input.focus(); return; }
if (sendBtn.disabled) return;
setBusy(true);
thread.appendChild(renderUserTurn(text));
var typing = renderTyping();
thread.appendChild(typing);
scrollToEnd();
var parentText = text;
input.value = "";
autosize();
try {
var sections = await callMakeExplanation(seed, text);
seed += 1;
typing.remove();
var structured = {
opener: (sections[0] || "").trim(),
body: (sections[1] || "").trim(),
closer: (sections[2] || "").trim(),
followup: (sections[3] || "").trim(),
};
thread.appendChild(renderFabellaTurn(structured, null));
scrollToEnd();
conversation.push({ role: "parent", content: parentText });
var narrationText = narrationFromSections(structured);
conversation.push({ role: "fabella", content: narrationText });
if (conversation.length > 12) conversation = conversation.slice(-12);
saveTurn(parentText, structured);
saveMemory(parentText, narrationText);
} catch (err) {
typing.remove();
thread.appendChild(renderError(String(err.message || err)));
scrollToEnd();
} finally {
setBusy(false);
}
});
init();
})();
</script>
</body>
</html>
"""
INDEX_HTML = (
INDEX_HTML # rebuilt for Phase 3 chat UI
.replace("__TONE_CHOICES__", "[" + ",".join('["' + v + '","' + l + '"]' for v, l in TONE_CHOICES) + "]")
.replace("__EXAMPLES__", "[" + ",".join('"' + s.replace('"', '\\"') + '"' for s in EXAMPLE_SITUATIONS) + "]")
)
@app.get("/", response_class=HTMLResponse)
async def homepage():
return HTMLResponse(content=INDEX_HTML, status_code=200)
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
app.launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("PORT", 7860)),
)