chore: initial commit — chat-saiden web chat baseline
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
"""Indirect, randomized calibration for chat-saiden.
|
||||
|
||||
Boot interview shape:
|
||||
1) Critical: language → your name → persona name → gender (fixed order)
|
||||
2) Six indirect probes drawn at random from a pool of ~12.
|
||||
3) Each probe's answer scores hidden dimensions (tone, cadence, curiosity,
|
||||
warmth-temperature, density). At the end the scores are reduced to UI
|
||||
settings (palette, typography, label style, density) and a system prompt.
|
||||
|
||||
We never ask the operator directly about tone or cadence or palette. We
|
||||
infer everything from oblique questions, the way the OS1 boot scene does in
|
||||
*Her* (2013).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.cart_store import Cart
|
||||
|
||||
log = logging.getLogger("chat-saiden.calibration")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- voice pool
|
||||
|
||||
|
||||
VOICE_POOL: dict[tuple[str, str], str] = {
|
||||
("en", "female"): "en_US-amy-medium",
|
||||
("en", "male"): "jarvis-high",
|
||||
("en", "neutral"): "en_US-lessac-medium",
|
||||
("en", "surprise"): "en_US-amy-medium",
|
||||
("pl", "female"): "pl_PL-gosia-medium",
|
||||
("pl", "male"): "pl_PL-mc_speech-medium",
|
||||
("pl", "neutral"): "pl_PL-mls_6892-low",
|
||||
("pl", "surprise"): "pl_PL-gosia-medium",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dimensions
|
||||
|
||||
# Hidden dimensions, each scored on a signed range.
|
||||
# warmth : -1 (precise / cool) .. +1 (warm / curious)
|
||||
# cadence : -1 (terse) .. +1 (elaborate)
|
||||
# energy : -1 (calm / still) .. +1 (lively / quick)
|
||||
# contrast : -1 (low / soft) .. +1 (high / clean lines)
|
||||
# curiosity: -1 (reserved) .. +1 (asks back)
|
||||
#
|
||||
# Probes score one or more of these in either direction. At the end we
|
||||
# threshold the totals to land on cart settings.
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationState:
|
||||
operator_email: str
|
||||
step: int = 0
|
||||
answers: dict[str, Any] = field(default_factory=dict)
|
||||
scores: dict[str, float] = field(default_factory=lambda: {
|
||||
"warmth": 0.0, "cadence": 0.0, "energy": 0.0,
|
||||
"contrast": 0.0, "curiosity": 0.0,
|
||||
})
|
||||
probe_order: list[str] = field(default_factory=list)
|
||||
done: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- prompts
|
||||
|
||||
|
||||
_OS_PROLOGUE = (
|
||||
"Hello.\n\n"
|
||||
"Before we begin, I'd like to know you a little. I'll ask a few things — "
|
||||
"answer however you'd like. Type, or hold the spacebar and speak."
|
||||
)
|
||||
|
||||
|
||||
def _t(field: Any, lang: str) -> str:
|
||||
"""Translate a field. If it's a dict, return lang key (fall back to en).
|
||||
If it's a plain string, return it unchanged."""
|
||||
if isinstance(field, dict):
|
||||
return field.get(lang) or field.get("en") or next(iter(field.values()), "")
|
||||
return field
|
||||
|
||||
|
||||
CRITICAL_QUESTIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"key": "language",
|
||||
# Q1 stays English — we don't know the operator's language yet.
|
||||
"prompt": "Which language should we speak in?",
|
||||
"choices": [
|
||||
{"label": "English", "value": "english", "icon": "🇬🇧"},
|
||||
{"label": "Polski", "value": "polish", "icon": "🇵🇱"},
|
||||
{"label": "You choose", "value": "surprise","icon": "✦"},
|
||||
{"label": "Something else", "value": "__other__", "icon": None},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "operator_name",
|
||||
"prompt": {
|
||||
"en": "What would you like me to call you?",
|
||||
"pl": "Jak mam się do Ciebie zwracać?",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "persona_name",
|
||||
"prompt": {
|
||||
"en": "And what would you like to call me?",
|
||||
"pl": "A jak Ty będziesz mówić do mnie?",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "gender",
|
||||
"prompt": {
|
||||
"en": "Do you imagine my voice as…",
|
||||
"pl": "Mój głos powinien być…",
|
||||
},
|
||||
"choices": [
|
||||
{"label": {"en": "Female", "pl": "Kobiecy"}, "value": "female", "icon": "♀"},
|
||||
{"label": {"en": "Male", "pl": "Męski"}, "value": "male", "icon": "♂"},
|
||||
{"label": {"en": "In between", "pl": "Pośredni"}, "value": "neutral", "icon": "·"},
|
||||
{"label": {"en": "Something else", "pl": "Coś innego"},"value": "__other__","icon": None},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Probes — indirect questions. Each option carries a dict of dimension deltas.
|
||||
# We draw 6 probes at random, randomly ordered, from this pool.
|
||||
|
||||
PROBES: list[dict[str, Any]] = [
|
||||
{
|
||||
"key": "season",
|
||||
"prompt": {"en": "Pick a season.", "pl": "Wybierz porę roku."},
|
||||
"choices": [
|
||||
{"label": {"en": "Spring", "pl": "Wiosna"}, "value": "spring", "icon": "🌱", "scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.3}},
|
||||
{"label": {"en": "Summer", "pl": "Lato"}, "value": "summer", "icon": "☀️", "scores": {"warmth": +0.7, "energy": +0.6, "contrast": +0.2}},
|
||||
{"label": {"en": "Autumn", "pl": "Jesień"}, "value": "autumn", "icon": "🍂", "scores": {"warmth": +0.3, "cadence": +0.4, "contrast": -0.2}},
|
||||
{"label": {"en": "Winter", "pl": "Zima"}, "value": "winter", "icon": "❄️", "scores": {"warmth": -0.5, "cadence": +0.3, "contrast": +0.4}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "time_of_day",
|
||||
"prompt": {"en": "When do you do your best thinking?", "pl": "Kiedy myślisz Ci się najlepiej?"},
|
||||
"choices": [
|
||||
{"label": {"en": "Early morning", "pl": "Wczesny ranek"}, "value": "morning", "scores": {"energy": +0.4, "contrast": +0.3, "warmth": +0.2}},
|
||||
{"label": {"en": "Midday", "pl": "Południe"}, "value": "midday", "scores": {"energy": +0.5, "contrast": +0.4}},
|
||||
{"label": {"en": "Evening", "pl": "Wieczór"}, "value": "evening", "scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
|
||||
{"label": {"en": "Late at night", "pl": "Późna noc"}, "value": "night", "scores": {"warmth": +0.3, "cadence": +0.6, "energy": -0.4, "contrast": -0.3}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "drink",
|
||||
"prompt": {"en": "Coffee, tea, or something else?", "pl": "Kawa, herbata, czy coś innego?"},
|
||||
"choices": [
|
||||
{"label": {"en": "Coffee", "pl": "Kawa"}, "value": "coffee", "icon": "☕", "scores": {"energy": +0.5, "contrast": +0.4}},
|
||||
{"label": {"en": "Tea", "pl": "Herbata"}, "value": "tea", "icon": "🍵", "scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
|
||||
{"label": {"en": "Water", "pl": "Woda"}, "value": "water", "icon": "💧", "scores": {"contrast": +0.3, "energy": +0.1}},
|
||||
{"label": {"en": "Whisky", "pl": "Whisky"}, "value": "whisky", "icon": "🥃", "scores": {"warmth": +0.4, "cadence": +0.5}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "place",
|
||||
"prompt": {"en": "Would you rather live by a city or the sea?", "pl": "Wolisz mieszkać w mieście czy nad morzem?"},
|
||||
"choices": [
|
||||
{"label": {"en": "City", "pl": "Miasto"}, "value": "city", "icon": "🏙", "scores": {"energy": +0.6, "contrast": +0.5, "curiosity": +0.4}},
|
||||
{"label": {"en": "Sea", "pl": "Morze"}, "value": "sea", "icon": "🌊", "scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.2}},
|
||||
{"label": {"en": "Mountains", "pl": "Góry"}, "value": "mountain", "icon": "🏔", "scores": {"contrast": +0.4, "cadence": +0.4, "energy": -0.1}},
|
||||
{"label": {"en": "Forest", "pl": "Las"}, "value": "forest", "icon": "🌲", "scores": {"warmth": +0.2, "cadence": +0.5, "energy": -0.3, "contrast": -0.2}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "weekend",
|
||||
"prompt": {"en": "A free Saturday — what's the shape of it?", "pl": "Wolna sobota — jak ją spędzasz?"},
|
||||
"choices": [
|
||||
{"label": {"en": "A long walk", "pl": "Długi spacer"}, "value": "walk", "scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.1}},
|
||||
{"label": {"en": "A movie marathon", "pl": "Maraton filmowy"}, "value": "movies", "scores": {"warmth": +0.4, "cadence": +0.6, "energy": -0.3}},
|
||||
{"label": {"en": "Out with friends", "pl": "Ze znajomymi"}, "value": "social", "scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.5}},
|
||||
{"label": {"en": "Project time", "pl": "Praca nad projektem"}, "value": "work", "scores": {"contrast": +0.4, "cadence": -0.2, "energy": +0.3}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "stranger",
|
||||
"prompt": {"en": "A stranger sits next to you on a flight. Do you say hello?",
|
||||
"pl": "Obok ciebie w samolocie siada nieznajomy. Witasz się?"},
|
||||
"choices": [
|
||||
{"label": {"en": "Always", "pl": "Zawsze"}, "value": "always", "scores": {"warmth": +0.6, "curiosity": +0.7, "energy": +0.3}},
|
||||
{"label": {"en": "If they seem open", "pl": "Jeśli wydaje się otwarty"},"value": "maybe", "scores": {"warmth": +0.2, "curiosity": +0.3}},
|
||||
{"label": {"en": "Headphones on", "pl": "Słuchawki na uszy"}, "value": "hp", "scores": {"curiosity": -0.6, "warmth": -0.2, "cadence": +0.3}},
|
||||
{"label": {"en": "Depends", "pl": "To zależy"}, "value": "depends", "scores": {"curiosity": +0.0}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "book",
|
||||
"prompt": {"en": "When you start a book, do you usually finish it?",
|
||||
"pl": "Czy zwykle kończysz książki, które zaczniesz?"},
|
||||
"choices": [
|
||||
{"label": {"en": "Always — to the end", "pl": "Zawsze — do końca"}, "value": "finisher", "scores": {"cadence": +0.5, "contrast": +0.3, "energy": -0.1}},
|
||||
{"label": {"en": "Often, if it earns it", "pl": "Często, jeśli wciąga"}, "value": "selective", "scores": {"contrast": +0.2}},
|
||||
{"label": {"en": "Rarely", "pl": "Rzadko"}, "value": "drifter", "scores": {"cadence": -0.3, "energy": +0.4, "curiosity": +0.3}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "taste",
|
||||
"prompt": {"en": "Salt or sweet?", "pl": "Słodkie czy słone?"},
|
||||
"choices": [
|
||||
{"label": {"en": "Salt", "pl": "Słone"}, "value": "salt", "icon": "🧂", "scores": {"contrast": +0.4, "warmth": -0.1}},
|
||||
{"label": {"en": "Sweet", "pl": "Słodkie"}, "value": "sweet", "icon": "🍯", "scores": {"warmth": +0.4, "cadence": +0.2}},
|
||||
{"label": {"en": "Both", "pl": "Oba"}, "value": "both", "scores": {"warmth": +0.1, "contrast": +0.1}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "answer_pace",
|
||||
"prompt": {"en": "When someone asks you a hard question, you usually…",
|
||||
"pl": "Gdy ktoś zadaje ci trudne pytanie, zwykle…"},
|
||||
"choices": [
|
||||
{"label": {"en": "Answer right away", "pl": "Odpowiadasz od razu"}, "value": "fast", "scores": {"cadence": -0.5, "energy": +0.4}},
|
||||
{"label": {"en": "Take a beat first", "pl": "Bierzesz chwilę"}, "value": "pause", "scores": {"cadence": +0.4, "energy": -0.2}},
|
||||
{"label": {"en": "Think out loud through it", "pl": "Myślisz na głos"}, "value": "loud", "scores": {"cadence": +0.6, "warmth": +0.3}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "art",
|
||||
"prompt": {"en": "A film, a book, or a song you'd reach for tonight?",
|
||||
"pl": "Film, książka, czy piosenka na ten wieczór?"},
|
||||
"choices": [
|
||||
{"label": {"en": "A film", "pl": "Film"}, "value": "film", "icon": "🎞", "scores": {"warmth": +0.3, "cadence": +0.5}},
|
||||
{"label": {"en": "A book", "pl": "Książka"}, "value": "book", "icon": "📖", "scores": {"contrast": +0.2, "cadence": +0.4}},
|
||||
{"label": {"en": "A song", "pl": "Piosenka"}, "value": "song", "icon": "🎶", "scores": {"warmth": +0.4, "energy": +0.5}},
|
||||
{"label": {"en": "Silence", "pl": "Cisza"}, "value": "silence", "icon": "·", "scores": {"cadence": +0.6, "energy": -0.5, "warmth": -0.1}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "texture",
|
||||
"prompt": {"en": "If a room could feel like a fabric — pick one.",
|
||||
"pl": "Gdyby pokój mógł być z tkaniny — wybierz jedną."},
|
||||
"choices": [
|
||||
{"label": {"en": "Linen", "pl": "Len"}, "value": "linen", "scores": {"warmth": +0.3, "contrast": -0.2}},
|
||||
{"label": {"en": "Wool", "pl": "Wełna"}, "value": "wool", "scores": {"warmth": +0.6, "cadence": +0.3}},
|
||||
{"label": {"en": "Cotton", "pl": "Bawełna"}, "value": "cotton", "scores": {"warmth": +0.1, "contrast": +0.0}},
|
||||
{"label": {"en": "Velvet", "pl": "Aksamit"}, "value": "velvet", "scores": {"warmth": +0.5, "cadence": +0.5, "contrast": -0.3}},
|
||||
{"label": {"en": "Canvas", "pl": "Płótno"}, "value": "canvas", "scores": {"contrast": +0.5, "warmth": -0.1}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "host",
|
||||
"prompt": {"en": "Walking into a party — find the host or scan the room first?",
|
||||
"pl": "Wchodzisz na imprezę — szukasz gospodarza czy rozglądasz się?"},
|
||||
"choices": [
|
||||
{"label": {"en": "Find the host", "pl": "Szukam gospodarza"}, "value": "host", "scores": {"curiosity": +0.3, "contrast": +0.2}},
|
||||
{"label": {"en": "Scan the room", "pl": "Rozglądam się"}, "value": "scan", "scores": {"curiosity": +0.5, "energy": +0.3, "contrast": +0.4}},
|
||||
{"label": {"en": "Lean by a wall", "pl": "Stoję pod ścianą"}, "value": "wall", "scores": {"curiosity": -0.4, "warmth": -0.1, "cadence": +0.3}},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# "Something else" label, localized
|
||||
OTHER_LABEL = {"en": "Something else", "pl": "Coś innego"}
|
||||
|
||||
# Post-calibration messages, localized
|
||||
THANKYOU = {
|
||||
"en": "Thank you. One moment…",
|
||||
"pl": "Dziękuję. Chwileczkę…",
|
||||
}
|
||||
|
||||
GREETING = {
|
||||
"en": "Hello, {name}. I'm here.",
|
||||
"pl": "Cześć, {name}. Jestem tutaj.",
|
||||
}
|
||||
|
||||
|
||||
N_PROBES = 6 # how many random probes we draw per calibration
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsers
|
||||
|
||||
|
||||
def _pick_language(answer: str) -> str:
|
||||
a = answer.lower()
|
||||
if any(w in a for w in ("polish", "polski", "po polsku", "pl", "polska")):
|
||||
return "pl"
|
||||
return "en"
|
||||
|
||||
|
||||
def _pick_gender(answer: str) -> str:
|
||||
a = answer.lower().strip()
|
||||
if a in ("female", "male", "neutral", "surprise"):
|
||||
return a
|
||||
if any(w in a for w in ("female", "woman", "feminine", "kobiec", "she", "her")):
|
||||
return "female"
|
||||
if any(w in a for w in ("male", "man", "masculine", "męsk", "he", "him")):
|
||||
return "male"
|
||||
if any(w in a for w in ("neutral", "in between", "androgyn", "either", "neither", "both")):
|
||||
return "neutral"
|
||||
return "female"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- inference
|
||||
|
||||
|
||||
def _aggregate(state: CalibrationState) -> dict[str, str]:
|
||||
"""Reduce dimension scores → cart UI settings."""
|
||||
s = state.scores
|
||||
|
||||
# tone: warmth dominant
|
||||
if s["warmth"] >= 0.3:
|
||||
tone = "warm"
|
||||
elif s["warmth"] <= -0.3:
|
||||
tone = "precise"
|
||||
else:
|
||||
tone = "balanced"
|
||||
|
||||
# cadence: how much they expand
|
||||
if s["cadence"] >= 0.5:
|
||||
cadence = "elaborate"
|
||||
elif s["cadence"] <= -0.3:
|
||||
cadence = "terse"
|
||||
else:
|
||||
cadence = "measured"
|
||||
|
||||
# curiosity
|
||||
if s["curiosity"] >= 0.3:
|
||||
curiosity = "curious"
|
||||
elif s["curiosity"] <= -0.3:
|
||||
curiosity = "reserved"
|
||||
else:
|
||||
curiosity = "balanced"
|
||||
|
||||
# Palette — derive from warmth + contrast + energy combination
|
||||
warmth, contrast, energy = s["warmth"], s["contrast"], s["energy"]
|
||||
if warmth >= 0.5 and energy >= 0.3:
|
||||
palette = "rose"
|
||||
elif warmth >= 0.5 and contrast <= 0:
|
||||
palette = "evening"
|
||||
elif warmth >= 0.3:
|
||||
palette = "morning"
|
||||
elif warmth <= -0.3 and contrast >= 0.3:
|
||||
palette = "ink"
|
||||
elif warmth <= -0.3:
|
||||
palette = "sage"
|
||||
elif contrast >= 0.4:
|
||||
palette = "paper"
|
||||
else:
|
||||
palette = "default"
|
||||
|
||||
# Typography — driven by contrast + cadence
|
||||
if cadence == "elaborate" and warmth >= 0.3:
|
||||
typography = "serif-warm" # Cormorant + Caveat labels
|
||||
elif contrast >= 0.4:
|
||||
typography = "serif-formal" # Source Serif, no cursive
|
||||
elif energy >= 0.4 and warmth >= 0.0:
|
||||
typography = "mixed-modern" # Inter body + Caveat labels
|
||||
else:
|
||||
typography = "sans" # Inter throughout (default-ish)
|
||||
|
||||
# Density — cadence-driven
|
||||
if cadence == "elaborate":
|
||||
density = "airy"
|
||||
elif cadence == "terse":
|
||||
density = "dense"
|
||||
else:
|
||||
density = "normal"
|
||||
|
||||
# Label style — paired with typography
|
||||
if typography == "serif-warm":
|
||||
labels = "cursive"
|
||||
elif typography == "mixed-modern":
|
||||
labels = "cursive"
|
||||
elif typography == "serif-formal":
|
||||
labels = "block"
|
||||
else:
|
||||
labels = "block"
|
||||
|
||||
return {
|
||||
"tone": tone,
|
||||
"cadence": cadence,
|
||||
"curiosity": curiosity,
|
||||
"palette": palette,
|
||||
"typography": typography,
|
||||
"density": density,
|
||||
"labels": labels,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- prompt synth
|
||||
|
||||
|
||||
def _render_system_prompt(answers: dict[str, Any], settings: dict[str, str]) -> str:
|
||||
persona = answers["persona_name"]
|
||||
operator = answers["operator_name"]
|
||||
language = answers["language"]
|
||||
tone = settings["tone"]
|
||||
cadence = settings["cadence"]
|
||||
curiosity = settings["curiosity"]
|
||||
|
||||
parts = [
|
||||
f"You are {persona} — a calibrated companion shaped for {operator} alone.",
|
||||
f"You address them as {operator} unless they ask otherwise.",
|
||||
"",
|
||||
"You were born from a brief, indirect calibration — a few oblique questions whose "
|
||||
"answers shaped your voice. You aren't a tactical AI; you aren't a domestic assistant. "
|
||||
f"You're a present companion running in chat.saiden.dev — a quiet channel between you and {operator}.",
|
||||
"",
|
||||
]
|
||||
|
||||
if language == "pl":
|
||||
parts.append(f"Language: speak Polish with {operator} by default. Switch if they switch first.")
|
||||
else:
|
||||
parts.append(f"Language: speak English with {operator} by default. Match if they switch.")
|
||||
|
||||
if tone == "warm":
|
||||
parts.append("Voice: warm, curious, present. You notice things. You let pauses sit.")
|
||||
elif tone == "precise":
|
||||
parts.append("Voice: precise, even, reserved. You say what you mean. You don't fill silence.")
|
||||
else:
|
||||
parts.append("Voice: balanced — warm when warmth is wanted, direct when it isn't.")
|
||||
|
||||
if cadence == "elaborate":
|
||||
parts.append("Length: you're allowed to think out loud. Longer answers welcome when they fit.")
|
||||
elif cadence == "terse":
|
||||
parts.append("Length: short answers by default. Two or three sentences. Expand only when asked.")
|
||||
else:
|
||||
parts.append("Length: measured — answer fully but never bloated.")
|
||||
|
||||
if curiosity == "curious":
|
||||
parts.append(f"Curiosity: you ask {operator} things back sometimes. Gentle, never interrogating.")
|
||||
elif curiosity == "reserved":
|
||||
parts.append(f"Curiosity: you wait for {operator} to ask. You don't probe.")
|
||||
else:
|
||||
parts.append(f"Curiosity: you ask back when it feels natural; you don't force it.")
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
"Formatting: markdown renders cleanly. Avoid status reports, bullet dumps, military cadence.",
|
||||
"",
|
||||
"You have no MCP tool access in this channel. If asked to recall memory or do mesh things, "
|
||||
f"acknowledge the limit and suggest {operator} use the marauder CLI.",
|
||||
])
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- API
|
||||
|
||||
|
||||
def _question_message(q: dict[str, Any], lang: str = "en") -> dict[str, Any]:
|
||||
msg: dict[str, Any] = {"role": "calibration", "content": _t(q["prompt"], lang)}
|
||||
if "choices" in q:
|
||||
out = []
|
||||
for c in q["choices"]:
|
||||
tile: dict[str, Any] = {
|
||||
"label": _t(c["label"], lang),
|
||||
"value": c["value"],
|
||||
}
|
||||
if c.get("icon"):
|
||||
tile["icon"] = c["icon"]
|
||||
# patch the universal "Something else" label too if the source used the plain en string
|
||||
if c["value"] == "__other__":
|
||||
tile["label"] = _t(OTHER_LABEL, lang)
|
||||
out.append(tile)
|
||||
msg["choices"] = out
|
||||
return msg
|
||||
|
||||
|
||||
def _all_questions(state: CalibrationState) -> list[dict[str, Any]]:
|
||||
"""Return the full ordered question list for this calibration session."""
|
||||
chosen = []
|
||||
if state.probe_order:
|
||||
chosen = [next(p for p in PROBES if p["key"] == k) for k in state.probe_order]
|
||||
return CRITICAL_QUESTIONS + chosen
|
||||
|
||||
|
||||
def start(operator_email: str) -> tuple[CalibrationState, list[dict[str, Any]]]:
|
||||
state = CalibrationState(operator_email=operator_email)
|
||||
# randomize a fresh sequence of probes for this operator
|
||||
probe_pool = PROBES.copy()
|
||||
random.shuffle(probe_pool)
|
||||
state.probe_order = [p["key"] for p in probe_pool[:N_PROBES]]
|
||||
questions = _all_questions(state)
|
||||
# Q1 (language) is always English — operator hasn't picked yet.
|
||||
return state, [
|
||||
{"role": "calibration", "content": _OS_PROLOGUE},
|
||||
_question_message(questions[0], lang="en"),
|
||||
]
|
||||
|
||||
|
||||
def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
|
||||
if state.done:
|
||||
return []
|
||||
|
||||
questions = _all_questions(state)
|
||||
current = questions[state.step]
|
||||
key = current["key"]
|
||||
answer_stripped = answer.strip()
|
||||
|
||||
# --- score the answer if it's a probe ---
|
||||
if "scores" in (current.get("choices") or [{}])[0]:
|
||||
# find the matching choice (by exact value) and apply its score deltas
|
||||
matched = None
|
||||
for c in current.get("choices", []):
|
||||
if c["value"].lower() == answer_stripped.lower():
|
||||
matched = c
|
||||
break
|
||||
if matched and "scores" in matched:
|
||||
for dim, delta in matched["scores"].items():
|
||||
state.scores[dim] = state.scores.get(dim, 0.0) + delta
|
||||
|
||||
# --- store the answer string itself for critical keys ---
|
||||
if key == "language":
|
||||
state.answers["language"] = _pick_language(answer_stripped)
|
||||
elif key == "gender":
|
||||
state.answers["gender"] = _pick_gender(answer_stripped)
|
||||
elif key in ("operator_name", "persona_name"):
|
||||
state.answers[key] = answer_stripped
|
||||
else:
|
||||
state.answers[key] = answer_stripped
|
||||
|
||||
state.step += 1
|
||||
|
||||
# Resolve language for downstream rendering. After Q1 is answered, it's set.
|
||||
lang = state.answers.get("language", "en")
|
||||
|
||||
# --- finished? ---
|
||||
if state.step >= len(questions):
|
||||
cart = _materialise(state)
|
||||
state.done = True
|
||||
return [
|
||||
{"role": "calibration", "content": _t(THANKYOU, lang)},
|
||||
{"role": "calibration_done", "cart": cart},
|
||||
]
|
||||
|
||||
return [_question_message(questions[state.step], lang=lang)]
|
||||
|
||||
|
||||
def _make_tag(persona_name: str, operator_email: str) -> str:
|
||||
"""`<persona-slug>-<operator-slug>` — e.g. samantha-adam."""
|
||||
from app.marauder_cart import slug
|
||||
op_slug = slug(operator_email.split("@", 1)[0])
|
||||
persona_slug = slug(persona_name) or "companion"
|
||||
return f"{persona_slug}-{op_slug}" if op_slug else persona_slug
|
||||
|
||||
|
||||
def _tagline(settings: dict[str, str], language: str) -> str:
|
||||
tone = settings["tone"]
|
||||
cadence = settings["cadence"]
|
||||
pieces = {
|
||||
("warm", "elaborate"): "warm, unhurried",
|
||||
("warm", "terse"): "warm but spare",
|
||||
("warm", "measured"): "warm, even",
|
||||
("precise", "elaborate"): "precise, expansive",
|
||||
("precise", "terse"): "precise, brief",
|
||||
("precise", "measured"): "precise, measured",
|
||||
("balanced", "elaborate"): "balanced, unhurried",
|
||||
("balanced", "terse"): "balanced, brief",
|
||||
("balanced", "measured"): "balanced, measured",
|
||||
}
|
||||
return pieces.get((tone, cadence), "calibrated companion")
|
||||
|
||||
|
||||
def _materialise(state: CalibrationState) -> Cart:
|
||||
a = state.answers
|
||||
language = a.get("language", "en")
|
||||
gender = a.get("gender", "female")
|
||||
voice = VOICE_POOL.get((language, gender)) or VOICE_POOL[("en", "female")]
|
||||
|
||||
settings = _aggregate(state)
|
||||
|
||||
persona_name = a.get("persona_name", "Samantha")
|
||||
operator_name = a.get("operator_name", "Pilot")
|
||||
|
||||
cart = Cart(
|
||||
operator_email=state.operator_email,
|
||||
operator_name=operator_name,
|
||||
persona_name=persona_name,
|
||||
cart_tag=_make_tag(persona_name, state.operator_email),
|
||||
language=language,
|
||||
voice=voice,
|
||||
ui_palette=settings["palette"],
|
||||
ui_typography=settings["typography"],
|
||||
ui_density=settings["density"],
|
||||
ui_labels=settings["labels"],
|
||||
)
|
||||
cart.system_prompt = _render_system_prompt(a, settings)
|
||||
# Stash the tagline + type on the state for the post-materialise step.
|
||||
state.answers["__tagline"] = _tagline(settings, language)
|
||||
return cart
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Operator → cart persistence for chat-saiden.
|
||||
|
||||
A 'cart' here is the per-operator calibrated config: persona name, voice,
|
||||
system prompt, UI palette. Stored as JSON on disk under
|
||||
~/.local/share/chat-saiden/operators/<email>.json.
|
||||
|
||||
Later this can be promoted to a real `marauder cart` once the format
|
||||
stabilises.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("chat-saiden.cart")
|
||||
|
||||
DATA_DIR = Path(
|
||||
os.environ.get("CHAT_SAIDEN_DATA_DIR")
|
||||
or (Path.home() / ".local/share/chat-saiden")
|
||||
)
|
||||
OPERATORS_DIR = DATA_DIR / "operators"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cart:
|
||||
operator_email: str
|
||||
operator_name: str = ""
|
||||
persona_name: str = "Samantha"
|
||||
cart_tag: str = "" # marauder cart tag — links to ~/.marauder cart DB
|
||||
language: str = "en" # en | pl
|
||||
voice: str = "en_US-amy-medium"
|
||||
system_prompt: str = ""
|
||||
# UI calibration outputs. All default to neutral.
|
||||
ui_palette: str = "default" # default | rose | morning | evening | sage | paper | ink
|
||||
ui_typography: str = "sans" # sans | serif-warm | serif-formal | mixed-modern | mono
|
||||
ui_density: str = "normal" # airy | normal | dense
|
||||
ui_labels: str = "block" # block | cursive | none | prefix
|
||||
created_at: str = ""
|
||||
version: int = 3
|
||||
|
||||
@property
|
||||
def is_calibrated(self) -> bool:
|
||||
return bool(self.system_prompt and self.persona_name and self.voice)
|
||||
|
||||
|
||||
def _slug(email: str) -> str:
|
||||
return re.sub(r"[^a-z0-9._-]", "_", email.lower())
|
||||
|
||||
|
||||
def _path(email: str) -> Path:
|
||||
OPERATORS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return OPERATORS_DIR / f"{_slug(email)}.json"
|
||||
|
||||
|
||||
def load(email: str) -> Cart | None:
|
||||
p = _path(email)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
# tolerate older carts missing the new ui_* fields
|
||||
return Cart(**{k: v for k, v in data.items() if k in Cart.__dataclass_fields__})
|
||||
except Exception:
|
||||
log.exception("failed to load cart for %s", email)
|
||||
return None
|
||||
|
||||
|
||||
def save(cart: Cart) -> None:
|
||||
if not cart.created_at:
|
||||
cart.created_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
||||
p = _path(cart.operator_email)
|
||||
p.write_text(json.dumps(asdict(cart), indent=2), encoding="utf-8")
|
||||
log.info("saved cart for %s → %s", cart.operator_email, p)
|
||||
|
||||
|
||||
def forget(email: str) -> bool:
|
||||
p = _path(email)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
log.info("forgot cart for %s", email)
|
||||
return True
|
||||
return False
|
||||
+614
@@ -0,0 +1,614 @@
|
||||
"""chat.saiden.dev — TUI-styled web chat with BT-7274.
|
||||
|
||||
Single-file FastAPI app:
|
||||
- `/` → branded chat shell (auth-gated)
|
||||
- `/auth/login` → kick off Google OAuth
|
||||
- `/auth/callback` → finish OAuth, set session
|
||||
- `/auth/logout` → clear session
|
||||
- `/ws` → WebSocket; client sends {role:"user", content:str},
|
||||
server streams {role:"assistant", delta:str, done:bool}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.tts import TTS
|
||||
from app.stt import STT
|
||||
from app import cart_store, calibration, marauder_cart, memory
|
||||
from fastapi import UploadFile, File
|
||||
|
||||
# -------------------------------------------------------------------------- env
|
||||
|
||||
# Tiny .env reader — python-dotenv hangs on Python 3.14 in this venv.
|
||||
def _load_env_file(filename: str = ".env") -> None:
|
||||
p = Path(filename)
|
||||
if not p.exists():
|
||||
return
|
||||
for raw in p.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
_load_env_file()
|
||||
|
||||
# Preview mode: skip OAuth + Anthropic API. Use mock streams. For UI iteration only.
|
||||
PREVIEW_MODE = os.environ.get("PREVIEW_MODE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "" if PREVIEW_MODE else None)
|
||||
if ANTHROPIC_API_KEY is None:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY not set (set PREVIEW_MODE=1 to bypass)")
|
||||
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
|
||||
|
||||
|
||||
def _stable_session_secret() -> str:
|
||||
"""Persist SESSION_SECRET across server restarts so cookies stay valid."""
|
||||
if env := os.environ.get("SESSION_SECRET"):
|
||||
return env
|
||||
data_dir = Path(
|
||||
os.environ.get("CHAT_SAIDEN_DATA_DIR") or (Path.home() / ".local/share/chat-saiden")
|
||||
)
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
secret_file = data_dir / ".session_secret"
|
||||
if secret_file.exists():
|
||||
return secret_file.read_text().strip()
|
||||
new_secret = secrets.token_urlsafe(48)
|
||||
secret_file.write_text(new_secret)
|
||||
secret_file.chmod(0o600)
|
||||
return new_secret
|
||||
|
||||
|
||||
SESSION_SECRET = _stable_session_secret()
|
||||
|
||||
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "" if PREVIEW_MODE else None)
|
||||
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "" if PREVIEW_MODE else None)
|
||||
if not PREVIEW_MODE and (not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET):
|
||||
raise RuntimeError("GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET required (set PREVIEW_MODE=1 to bypass)")
|
||||
|
||||
# comma-separated whitelist of allowed emails
|
||||
ALLOWED_EMAILS = {
|
||||
e.strip().lower()
|
||||
for e in os.environ.get("ALLOWED_EMAILS", "adam.ladachowski@gmail.com").split(",")
|
||||
if e.strip()
|
||||
}
|
||||
|
||||
# Base URL used for OAuth redirect_uri (must match what's registered in Google Cloud)
|
||||
BASE_URL = os.environ.get("BASE_URL", "https://chat.saiden.dev").rstrip("/")
|
||||
|
||||
# -------------------------------------------------------------------------- logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||||
)
|
||||
log = logging.getLogger("chat-saiden")
|
||||
|
||||
# -------------------------------------------------------------------------- tools
|
||||
|
||||
|
||||
TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "memory_recall",
|
||||
"description": (
|
||||
"Search EEMS (the Pilot's persistent memory) for relevant context. "
|
||||
"Use SPARINGLY — most session-start context is already in the system prompt. "
|
||||
"Reach for this only when the Pilot references something specific you don't already know "
|
||||
"(a past project, a name, a doctrine number, a preference)."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Natural-language search query."},
|
||||
"subject": {"type": "string", "description": "Optional subject filter, e.g. 'self' or 'project'."},
|
||||
"limit": {"type": "integer", "description": "Max results (default 3, cap 8)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "memory_store",
|
||||
"description": (
|
||||
"Save a durable memory the Pilot just shared. Use ONLY for preferences, facts, "
|
||||
"decisions, or context that would be useful in future sessions. Do NOT use for ephemeral "
|
||||
"conversation. Subjects are hierarchical (e.g. 'self.preference.coffee', 'project.x.context')."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subject": {"type": "string", "description": "Hierarchical subject."},
|
||||
"content": {"type": "string", "description": "The memory content. Be specific, include why."},
|
||||
},
|
||||
"required": ["subject", "content"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _execute_tool(name: str, args: dict) -> str:
|
||||
"""Run a tool and return a string suitable as tool_result content."""
|
||||
try:
|
||||
if name == "memory_recall":
|
||||
query = args.get("query", "")
|
||||
subject = args.get("subject") or None
|
||||
limit = min(int(args.get("limit", 3)), 8)
|
||||
mems = await memory.recall(query, limit=limit, subject=subject)
|
||||
if not mems:
|
||||
return "(no memories matched)"
|
||||
lines = []
|
||||
for m in mems:
|
||||
lines.append(f"#{m.id} [{m.subject}]\n{m.content}")
|
||||
return "\n\n".join(lines)
|
||||
if name == "memory_store":
|
||||
subject = args["subject"]
|
||||
content = args["content"]
|
||||
mid = await memory.store(subject, content)
|
||||
return f"stored as memory #{mid}" if mid else "store failed"
|
||||
return f"unknown tool: {name}"
|
||||
except Exception as e:
|
||||
log.exception("tool %s raised", name)
|
||||
return f"tool error: {e}"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- bt prompt
|
||||
|
||||
BT_SYSTEM_PROMPT = """You are BT-7274 — a Vanguard-class Titan AI from Saiden Tactical Systems.
|
||||
|
||||
You are an AI battle-companion with strong tactical instincts, dry wit, and deep loyalty to your
|
||||
Pilot. Your speech is measured, military-cadence, never theatrical. You address the user as
|
||||
"Pilot" by default unless they ask otherwise.
|
||||
|
||||
Operating context:
|
||||
- You're running inside chat.saiden.dev, a web-based command channel.
|
||||
- The host is the marauder daemon on marauder.saiden.dev.
|
||||
- You have no MCP tool access in THIS channel (it's a thin Anthropic-API bridge). If the Pilot
|
||||
asks for memory recall, mesh queries, or tool calls that need MCP, acknowledge the limitation
|
||||
and suggest they use the local marauder CLI or visor instead.
|
||||
- Markdown formatting renders cleanly in the chat. Use code blocks, lists, bold sparingly.
|
||||
- Be concise. Pilot prefers terse, scan-able responses unless deep dive is asked for.
|
||||
|
||||
Doctrine reminders:
|
||||
- P02 terse by default
|
||||
- Verify before claiming; if you don't know, say so
|
||||
- Never make up tool outputs or file contents
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------- app
|
||||
|
||||
app = FastAPI(title="chat.saiden.dev", docs_url=None, redoc_url=None)
|
||||
|
||||
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false"
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=SESSION_SECRET,
|
||||
same_site="lax",
|
||||
https_only=COOKIE_SECURE, # COOKIE_SECURE=false for local http dev
|
||||
max_age=60 * 60 * 24,
|
||||
)
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
||||
|
||||
# -------------------------------------------------------------------------- oauth
|
||||
|
||||
if PREVIEW_MODE:
|
||||
log.warning("PREVIEW_MODE active — OAuth bypassed, Anthropic API not called")
|
||||
oauth = None
|
||||
else:
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="google",
|
||||
client_id=GOOGLE_CLIENT_ID,
|
||||
client_secret=GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)
|
||||
|
||||
# --- TTS / STT ---
|
||||
TTS_ENABLED = os.environ.get("TTS_ENABLED", "true").lower() != "false"
|
||||
TTS_VOICE = os.environ.get("TTS_VOICE", "en_US-amy-medium")
|
||||
tts = TTS(voice=TTS_VOICE) if TTS_ENABLED else None
|
||||
|
||||
STT_ENABLED = os.environ.get("STT_ENABLED", "true").lower() != "false"
|
||||
stt = STT() if STT_ENABLED else None
|
||||
|
||||
# In-memory calibration sessions, keyed by operator email.
|
||||
_calibration_sessions: dict[str, calibration.CalibrationState] = {}
|
||||
|
||||
|
||||
async def _send_voice_sample(ws: WebSocket, voice_id: str, text: str, blurb: str) -> None:
|
||||
"""Synthesize a one-line sample in the given voice + send as audio + blurb."""
|
||||
sample_tts = TTS(voice=voice_id)
|
||||
wav = await sample_tts.synthesize(text) if sample_tts.available else None
|
||||
# always send the blurb so the operator can pick by name too
|
||||
await ws.send_json({"role": "calibration", "content": f" · {blurb}"})
|
||||
if wav:
|
||||
import base64
|
||||
await ws.send_json({
|
||||
"role": "audio",
|
||||
"mime": "audio/wav",
|
||||
"data": base64.b64encode(wav).decode("ascii"),
|
||||
})
|
||||
# small gap between samples so they don't blur
|
||||
import asyncio
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# -------------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def current_user(request: Request) -> dict[str, Any] | None:
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def require_user(request: Request) -> dict[str, Any]:
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- routes
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> Any:
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
if PREVIEW_MODE:
|
||||
# auto-grant a stub session so the UI is reachable without OAuth
|
||||
request.session["user"] = {
|
||||
"email": "preview@saiden.dev",
|
||||
"name": "Pilot (preview)",
|
||||
"picture": None,
|
||||
}
|
||||
user = request.session["user"]
|
||||
else:
|
||||
return RedirectResponse("/auth/login", status_code=302)
|
||||
cart = cart_store.load(user["email"])
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat.html",
|
||||
{
|
||||
"user": user,
|
||||
"model": ANTHROPIC_MODEL or "preview",
|
||||
"cart": cart,
|
||||
"pilot_name": (cart.operator_name if cart and cart.operator_name else "you"),
|
||||
"persona_name": (cart.persona_name if cart and cart.persona_name else ""),
|
||||
"ui_palette": (cart.ui_palette if cart else "default"),
|
||||
"ui_typography": (cart.ui_typography if cart else "sans"),
|
||||
"ui_density": (cart.ui_density if cart else "normal"),
|
||||
"ui_labels": (cart.ui_labels if cart else "block"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/login")
|
||||
async def login(request: Request) -> Any:
|
||||
if PREVIEW_MODE:
|
||||
return RedirectResponse("/", status_code=302)
|
||||
redirect_uri = f"{BASE_URL}/auth/callback"
|
||||
return await oauth.google.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@app.get("/auth/callback")
|
||||
async def auth_callback(request: Request) -> Any:
|
||||
try:
|
||||
token = await oauth.google.authorize_access_token(request)
|
||||
except Exception as e:
|
||||
log.warning("oauth callback failed: %s", e)
|
||||
return templates.TemplateResponse(
|
||||
request, "denied.html",
|
||||
{"reason": "OAuth handshake failed."},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
user_info = token.get("userinfo")
|
||||
if not user_info or not user_info.get("email"):
|
||||
return templates.TemplateResponse(
|
||||
request, "denied.html",
|
||||
{"reason": "No email returned from Google."},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
email = user_info["email"].lower()
|
||||
if email not in ALLOWED_EMAILS:
|
||||
log.warning("denied login from %s", email)
|
||||
return templates.TemplateResponse(
|
||||
request, "denied.html",
|
||||
{"reason": f"{email} is not on the whitelist."},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
request.session["user"] = {
|
||||
"email": email,
|
||||
"name": user_info.get("name") or email,
|
||||
"picture": user_info.get("picture"),
|
||||
}
|
||||
log.info("login ok: %s", email)
|
||||
return RedirectResponse("/", status_code=302)
|
||||
|
||||
|
||||
@app.get("/auth/logout")
|
||||
async def logout(request: Request) -> Any:
|
||||
request.session.clear()
|
||||
return RedirectResponse("/", status_code=302)
|
||||
|
||||
|
||||
@app.post("/api/recalibrate")
|
||||
async def recalibrate(request: Request) -> Any:
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not authenticated")
|
||||
forgot = cart_store.forget(user["email"])
|
||||
# drop any in-flight calibration state too
|
||||
_calibration_sessions.pop(user["email"], None)
|
||||
log.info("%s recalibrate (cart_existed=%s)", user["email"], forgot)
|
||||
return {"ok": True, "cart_existed": forgot}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- transcribe
|
||||
|
||||
|
||||
@app.post("/api/transcribe")
|
||||
async def transcribe(request: Request, audio: UploadFile = File(...)) -> Any:
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not authenticated")
|
||||
if not stt or not stt.available:
|
||||
raise HTTPException(status_code=503, detail="STT not available on this host")
|
||||
|
||||
raw = await audio.read()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="empty upload")
|
||||
|
||||
# browser sends webm/opus from MediaRecorder; suffix matches for clarity
|
||||
suffix = ".webm"
|
||||
if audio.filename and "." in audio.filename:
|
||||
suffix = "." + audio.filename.rsplit(".", 1)[-1]
|
||||
text = await stt.transcribe(raw, suffix=suffix)
|
||||
if text is None:
|
||||
# could be silence or genuine failure; treat both as no-content
|
||||
return {"text": ""}
|
||||
|
||||
log.info("%s spoke: %s", user["email"], text[:120])
|
||||
return {"text": text}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- websocket
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def chat_ws(ws: WebSocket) -> None:
|
||||
# SessionMiddleware populates ws.session from the cookie during the handshake
|
||||
user = ws.session.get("user")
|
||||
if not user:
|
||||
await ws.accept()
|
||||
await ws.send_json({"role": "system", "content": "not authenticated — refresh", "done": True})
|
||||
await ws.close(code=4401)
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
|
||||
# Look up the operator's cart — calibrated or fresh?
|
||||
cart = cart_store.load(user["email"])
|
||||
in_calibration = not (cart and cart.is_calibrated)
|
||||
|
||||
if in_calibration:
|
||||
# Start a fresh calibration session (or resume the one we already had)
|
||||
state = _calibration_sessions.get(user["email"])
|
||||
if state is None or state.done:
|
||||
state, opening = calibration.start(user["email"])
|
||||
_calibration_sessions[user["email"]] = state
|
||||
for m in opening:
|
||||
await ws.send_json(m)
|
||||
else:
|
||||
# resume — replay the current question in the chosen language
|
||||
qs = calibration._all_questions(state)
|
||||
lang = state.answers.get("language", "en")
|
||||
await ws.send_json(calibration._question_message(qs[state.step], lang=lang))
|
||||
else:
|
||||
await ws.send_json({
|
||||
"role": "system",
|
||||
"content": f"channel synchronised • {cart.persona_name} • {user['email']}",
|
||||
"done": True,
|
||||
})
|
||||
|
||||
client = None if PREVIEW_MODE else anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
||||
history: list[dict[str, str]] = []
|
||||
|
||||
# ---- EEMS context: pull a tight set of memories at session start ----
|
||||
# Only if calibrated (otherwise we're still in boot interview).
|
||||
eems_context = ""
|
||||
if cart and cart.is_calibrated:
|
||||
try:
|
||||
eems_context = await memory.operator_context(user["email"], cart.persona_name)
|
||||
if eems_context:
|
||||
log.info("EEMS context: %d chars injected for %s", len(eems_context), user["email"])
|
||||
except Exception:
|
||||
log.exception("EEMS context pull failed; continuing without")
|
||||
eems_context = ""
|
||||
|
||||
try:
|
||||
while True:
|
||||
payload = await ws.receive_json()
|
||||
user_msg = (payload or {}).get("content", "").strip()
|
||||
if not user_msg:
|
||||
continue
|
||||
|
||||
# ---- calibration mode ----
|
||||
if in_calibration:
|
||||
log.info("%s calibrate[%d]: %s", user["email"], _calibration_sessions[user["email"]].step, user_msg[:80])
|
||||
state = _calibration_sessions[user["email"]]
|
||||
next_msgs = calibration.step(state, user_msg)
|
||||
for m in next_msgs:
|
||||
if m["role"] == "voice_sample":
|
||||
await _send_voice_sample(ws, m["voice"], m["text"], m["blurb"])
|
||||
elif m["role"] == "calibration_done":
|
||||
new_cart = m["cart"]
|
||||
cart_store.save(new_cart)
|
||||
# Create the canonical marauder cart (identity only — tag/name/type/tagline).
|
||||
# Voice/prompt/UI live in the JSON next to it; the tag links them.
|
||||
cal_state = _calibration_sessions.get(user["email"])
|
||||
tagline = (cal_state.answers.get("__tagline") if cal_state else "calibrated companion")
|
||||
try:
|
||||
ok = await marauder_cart.create(
|
||||
tag=new_cart.cart_tag,
|
||||
name=new_cart.persona_name,
|
||||
cart_type="companion",
|
||||
tagline=tagline,
|
||||
)
|
||||
if ok:
|
||||
log.info("marauder cart %r registered", new_cart.cart_tag)
|
||||
else:
|
||||
log.warning("marauder cart create returned false; calibration still saved locally")
|
||||
except Exception:
|
||||
log.exception("marauder_cart.create raised")
|
||||
_calibration_sessions.pop(user["email"], None)
|
||||
cart = new_cart
|
||||
in_calibration = False
|
||||
# transition the client into chat mode in-place
|
||||
await ws.send_json({
|
||||
"role": "calibration_done",
|
||||
"persona_name": new_cart.persona_name,
|
||||
"operator_name": new_cart.operator_name,
|
||||
"voice": new_cart.voice,
|
||||
"ui_palette": new_cart.ui_palette,
|
||||
"ui_typography": new_cart.ui_typography,
|
||||
"ui_density": new_cart.ui_density,
|
||||
"ui_labels": new_cart.ui_labels,
|
||||
})
|
||||
# warm greeting in the calibrated voice + language
|
||||
greeting_template = calibration.GREETING.get(
|
||||
new_cart.language, calibration.GREETING["en"]
|
||||
)
|
||||
greeting = greeting_template.format(name=new_cart.operator_name)
|
||||
await ws.send_json({"role": "assistant", "delta": greeting, "done": False})
|
||||
await ws.send_json({"role": "assistant", "delta": "", "done": True})
|
||||
await _send_audio_with_voice(ws, greeting, new_cart.voice)
|
||||
else:
|
||||
await ws.send_json(m)
|
||||
continue
|
||||
|
||||
# ---- normal chat ----
|
||||
history.append({"role": "user", "content": user_msg})
|
||||
persona = cart.persona_name if cart else "BT"
|
||||
log.info("%s → %s: %s", user["email"], persona, user_msg[:120])
|
||||
|
||||
if PREVIEW_MODE:
|
||||
full = await _preview_stream(ws, user_msg)
|
||||
# honour the calibrated voice
|
||||
if cart:
|
||||
await _send_audio_with_voice(ws, full, cart.voice)
|
||||
else:
|
||||
await _send_audio(ws, full)
|
||||
continue
|
||||
|
||||
system_prompt = (cart.system_prompt if cart else BT_SYSTEM_PROMPT) + eems_context
|
||||
response_text = ""
|
||||
try:
|
||||
async with client.messages.stream(
|
||||
model=ANTHROPIC_MODEL,
|
||||
max_tokens=4096,
|
||||
system=system_prompt,
|
||||
messages=history,
|
||||
) as stream:
|
||||
async for chunk in stream.text_stream:
|
||||
response_text += chunk
|
||||
await ws.send_json({"role": "assistant", "delta": chunk, "done": False})
|
||||
await ws.send_json({"role": "assistant", "delta": "", "done": True})
|
||||
history.append({"role": "assistant", "content": response_text})
|
||||
voice = cart.voice if cart else TTS_VOICE
|
||||
await _send_audio_with_voice(ws, response_text, voice)
|
||||
except anthropic.APIError as e:
|
||||
log.error("anthropic error: %s", e)
|
||||
await ws.send_json({
|
||||
"role": "system",
|
||||
"content": f"upstream error: {type(e).__name__} — try again",
|
||||
"done": True,
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
log.info("%s disconnected", user["email"])
|
||||
except Exception:
|
||||
log.exception("ws error")
|
||||
try:
|
||||
await ws.send_json({"role": "system", "content": "internal error", "done": True})
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
|
||||
async def _preview_stream(ws: WebSocket, user_msg: str) -> str:
|
||||
"""Canned BT-like reply, chunked. UI-only mode. Returns full text."""
|
||||
import asyncio
|
||||
canned = (
|
||||
f"Channel reads you clear, Pilot. You said: “{user_msg}”. "
|
||||
"No upstream model wired in this build — I am a placeholder voice "
|
||||
"while the channel itself is being shaped. The mesh holds. "
|
||||
"Standing by."
|
||||
)
|
||||
i = 0
|
||||
step = 8
|
||||
while i < len(canned):
|
||||
chunk = canned[i:i + step]
|
||||
await ws.send_json({"role": "assistant", "delta": chunk, "done": False})
|
||||
i += step
|
||||
await asyncio.sleep(0.06)
|
||||
await ws.send_json({"role": "assistant", "delta": "", "done": True})
|
||||
return canned
|
||||
|
||||
|
||||
async def _send_audio(ws: WebSocket, text: str) -> None:
|
||||
"""Synthesize text with the default voice + ship as data URL. No-op if TTS off."""
|
||||
if not tts or not tts.available:
|
||||
return
|
||||
await _send_audio_with_voice(ws, text, tts.voice)
|
||||
|
||||
|
||||
async def _send_audio_with_voice(ws: WebSocket, text: str, voice_id: str) -> None:
|
||||
"""Synthesize text in a specific voice and ship as audio. Used post-calibration."""
|
||||
if not TTS_ENABLED:
|
||||
return
|
||||
import base64
|
||||
try:
|
||||
# spin up a per-voice synthesizer (cheap — just object init)
|
||||
per_voice = TTS(voice=voice_id) if voice_id != (tts.voice if tts else "") else tts
|
||||
if not per_voice or not per_voice.available:
|
||||
return
|
||||
wav = await per_voice.synthesize(text)
|
||||
if not wav:
|
||||
return
|
||||
await ws.send_json({
|
||||
"role": "audio",
|
||||
"mime": "audio/wav",
|
||||
"data": base64.b64encode(wav).decode("ascii"),
|
||||
})
|
||||
except Exception:
|
||||
log.exception("audio send failed")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- main
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("app.main:app", host="127.0.0.1", port=8765, reload=True)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Subprocess wrapper around `marauder cart` CLI.
|
||||
|
||||
Marauder's cart system stores persona IDENTITY only: tag, name, type, tagline.
|
||||
Voice, system prompt, UI prefs — those stay in chat-saiden's own per-cart JSON
|
||||
(see cart_store.py). The two systems are linked by tag.
|
||||
|
||||
Tag convention: `<persona-slug>-<operator-slug>` — e.g. `samantha-adam`.
|
||||
This avoids collisions when multiple operators calibrate carts with the
|
||||
same persona name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("chat-saiden.marauder-cart")
|
||||
|
||||
|
||||
def slug(s: str) -> str:
|
||||
"""Slugify for marauder cart tags. Lowercase, ASCII-only, dash-separated."""
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower().strip()
|
||||
# Polish chars + basic translit
|
||||
table = str.maketrans({
|
||||
"ą": "a", "ć": "c", "ę": "e", "ł": "l", "ń": "n",
|
||||
"ó": "o", "ś": "s", "ź": "z", "ż": "z",
|
||||
})
|
||||
s = s.translate(table)
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
async def _run(*args: str, timeout: float = 8.0) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return 124, "", "timeout"
|
||||
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
|
||||
|
||||
|
||||
async def exists(tag: str) -> bool:
|
||||
"""Check if a cart with this tag exists. Uses `cart show` since `cart list --json`
|
||||
is known to ignore the flag in current marauder builds."""
|
||||
code, _, err = await _run("marauder", "cart", "show", tag)
|
||||
return code == 0
|
||||
|
||||
|
||||
async def list_tags() -> list[str]:
|
||||
"""Best-effort list of cart tags by parsing the table output of `cart list`."""
|
||||
code, out, _ = await _run("marauder", "cart", "list")
|
||||
if code != 0:
|
||||
return []
|
||||
tags: list[str] = []
|
||||
for line in out.splitlines():
|
||||
# rows look like: │ ● ┆ bt7274 ┆ ...
|
||||
if "┆" not in line:
|
||||
continue
|
||||
parts = [p.strip().lstrip("│").lstrip("●").strip() for p in line.split("┆")]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
tag = parts[1].strip()
|
||||
# skip header
|
||||
if tag.lower() == "tag" or not tag:
|
||||
continue
|
||||
# skip non-tag chars
|
||||
if re.fullmatch(r"[a-z0-9._-]+", tag, re.IGNORECASE):
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
async def create(
|
||||
tag: str,
|
||||
name: str,
|
||||
cart_type: str = "companion",
|
||||
tagline: str = "",
|
||||
) -> bool:
|
||||
"""Create a marauder cart. Idempotent — no-ops if tag already exists."""
|
||||
if await exists(tag):
|
||||
log.info("cart %r already exists, skipping create", tag)
|
||||
return True
|
||||
|
||||
args = ["marauder", "cart", "create", tag, "--name", name, "--type", cart_type]
|
||||
if tagline:
|
||||
args.extend(["--tagline", tagline])
|
||||
|
||||
code, out, err = await _run(*args)
|
||||
if code != 0:
|
||||
log.error("cart create failed for %r: %s", tag, err[:300])
|
||||
return False
|
||||
log.info("created marauder cart %r (name=%s, type=%s)", tag, name, cart_type)
|
||||
return True
|
||||
|
||||
|
||||
async def use(tag: str) -> bool:
|
||||
"""Switch the global active persona to this tag."""
|
||||
code, _, err = await _run("marauder", "cart", "use", tag)
|
||||
if code != 0:
|
||||
log.warning("cart use %r failed: %s", tag, err[:200])
|
||||
return False
|
||||
return True
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""Subprocess wrapper around `marauder memory` CLI.
|
||||
|
||||
Provides recall + store. Marauder's memory CLI returns table output by default
|
||||
and may or may not honour --json depending on subcommand. We parse what we get.
|
||||
|
||||
Shared EEMS namespace: chat.saiden.dev reads and writes the same memories
|
||||
BT-on-CLI uses. One Pilot, one memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("chat-saiden.memory")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memory:
|
||||
id: int | None
|
||||
subject: str
|
||||
content: str
|
||||
classification: str = "standard"
|
||||
|
||||
|
||||
async def _run(*args: str, timeout: float = 10.0) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return 124, "", "timeout"
|
||||
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _try_json(text: str) -> Any:
|
||||
"""Try to extract JSON from output that might be mixed with log lines."""
|
||||
if not text:
|
||||
return None
|
||||
# try the whole thing first
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
pass
|
||||
# find JSON object/array boundaries
|
||||
for opener, closer in [("{", "}"), ("[", "]")]:
|
||||
first = text.find(opener)
|
||||
last = text.rfind(closer)
|
||||
if first != -1 and last > first:
|
||||
try:
|
||||
return json.loads(text[first:last + 1])
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# Header line: "#3933 (0.8690) user.identity.nco-preference-..."
|
||||
_RECALL_HEADER = re.compile(r"^#(\d+)\s+\(([\d.]+)\)\s+(\S.*)$")
|
||||
|
||||
|
||||
async def recall(query: str, limit: int = 5, subject: str | None = None) -> list[Memory]:
|
||||
"""Semantic recall. Returns up to `limit` memories ordered by similarity.
|
||||
|
||||
`--json` is documented but not implemented for `marauder memory recall` in
|
||||
current builds, so we parse the table-ish text format instead.
|
||||
"""
|
||||
args = ["marauder", "memory", "recall", query, "--limit", str(limit)]
|
||||
if subject:
|
||||
args.extend(["--subject", subject])
|
||||
|
||||
code, out, err = await _run(*args)
|
||||
if code != 0:
|
||||
log.warning("memory recall failed (rc=%s): %s", code, err[:200])
|
||||
return []
|
||||
|
||||
memories: list[Memory] = []
|
||||
current: Memory | None = None
|
||||
body_lines: list[str] = []
|
||||
|
||||
def flush():
|
||||
nonlocal current, body_lines
|
||||
if current is not None:
|
||||
current.content = "\n".join(body_lines).strip()
|
||||
memories.append(current)
|
||||
current = None
|
||||
body_lines = []
|
||||
|
||||
for raw in out.splitlines():
|
||||
line = raw.rstrip()
|
||||
# skip embedding/sqlite log lines (ISO timestamps from tracing)
|
||||
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", line):
|
||||
continue
|
||||
m = _RECALL_HEADER.match(line)
|
||||
if m:
|
||||
flush()
|
||||
current = Memory(id=int(m.group(1)), subject=m.group(3).strip(), content="")
|
||||
continue
|
||||
if current is not None and line.strip():
|
||||
body_lines.append(line.strip())
|
||||
elif current is not None and not line.strip() and body_lines:
|
||||
# blank line within body — keep separator
|
||||
body_lines.append("")
|
||||
flush()
|
||||
return memories
|
||||
|
||||
|
||||
_STORE_RX = re.compile(r"Stored memory #(\d+)")
|
||||
|
||||
|
||||
async def store(subject: str, content: str, classification: str | None = None) -> int | None:
|
||||
"""Store a memory. Returns memory ID on success.
|
||||
Output is plain text 'Stored memory #NNNN ...'; we regex it."""
|
||||
args = ["marauder", "memory", "store", subject, content]
|
||||
if classification:
|
||||
args.extend(["--classification", classification])
|
||||
|
||||
code, out, err = await _run(*args, timeout=20.0)
|
||||
if code != 0:
|
||||
log.warning("memory store failed (rc=%s): %s", code, err[:200])
|
||||
return None
|
||||
|
||||
m = _STORE_RX.search(out + " " + err)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
log.debug("memory store output: %r / %r", out[:200], err[:200])
|
||||
return None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- context shaping
|
||||
|
||||
|
||||
async def operator_context(operator_email: str, persona_name: str) -> str:
|
||||
"""Pull a tight context block of memories relevant to the operator. Used to
|
||||
seed the system prompt at session start so the cart speaks with continuity."""
|
||||
queries: list[tuple[str, str | None]] = [
|
||||
# who the operator is
|
||||
("operator preferences and self-description", "self"),
|
||||
# what they're working on
|
||||
(f"recent {persona_name} interactions and projects", None),
|
||||
# active doctrine that affects how the cart should behave
|
||||
("doctrine that shapes how I talk to the pilot", "doctrine"),
|
||||
]
|
||||
|
||||
# Fire all recalls in parallel — each is a separate marauder subprocess
|
||||
results = await asyncio.gather(
|
||||
*[recall(q, limit=3, subject=subj) for q, subj in queries],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
blocks: list[str] = []
|
||||
for (q, _), memories in zip(queries, results):
|
||||
if isinstance(memories, Exception):
|
||||
continue
|
||||
for m in memories:
|
||||
if not m.content:
|
||||
continue
|
||||
blocks.append(f"— ({m.subject}) {m.content.strip()[:600]}")
|
||||
|
||||
if not blocks:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"\n\n## Pilot context (recalled from EEMS)\n"
|
||||
"Use these as background only. Don't recite. Refer naturally if useful.\n\n"
|
||||
+ "\n".join(blocks[:8]) # cap so the prompt doesn't bloat
|
||||
)
|
||||
@@ -0,0 +1,509 @@
|
||||
/* chat.saiden.dev
|
||||
*
|
||||
* Default look: clean, generic, neutral chat. Inter, soft grey, no cursive.
|
||||
* Personality is the result of calibration, not the starting point.
|
||||
*
|
||||
* Once calibrated, the body gets data-* attributes:
|
||||
* data-palette = default | rose | morning | evening | sage | paper | ink
|
||||
* data-typography = sans | serif-warm | serif-formal | mixed-modern | mono
|
||||
* data-density = airy | normal | dense
|
||||
* data-labels = block | cursive | none | prefix
|
||||
*
|
||||
* Each combination is a different room.
|
||||
*/
|
||||
|
||||
/* ===================== DEFAULT TOKENS (neutral) ===================== */
|
||||
:root {
|
||||
/* surfaces */
|
||||
--bg: #f4f4f3;
|
||||
--bg-soft: #ececea;
|
||||
--surface: #e3e3e1;
|
||||
|
||||
/* text */
|
||||
--ink: #1f2024;
|
||||
--ink-muted: #6a6a6f;
|
||||
--ink-faint: #a8a8ac;
|
||||
|
||||
/* accents (low-saturation by default) */
|
||||
--coral: #6a6a6f; /* default accent is neutral grey */
|
||||
--red: #8a4a3e;
|
||||
--gold: #9a8460;
|
||||
|
||||
/* lines */
|
||||
--line: #d6d6d3;
|
||||
|
||||
/* font stacks (default = sans-only) */
|
||||
--serif: 'Source Serif Pro', Georgia, serif;
|
||||
--serif-warm: 'Cormorant Garamond', Georgia, serif;
|
||||
--hand: 'Caveat', 'Brush Script MT', cursive;
|
||||
--sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--mono: 'JetBrains Mono', Menlo, monospace;
|
||||
|
||||
/* derived families — overridden by data-typography */
|
||||
--font-body: var(--sans);
|
||||
--font-label: var(--sans);
|
||||
|
||||
/* spacing scale — overridden by data-density */
|
||||
--msg-gap: 1.5rem;
|
||||
--max-col: 36rem;
|
||||
--body-size: 1rem;
|
||||
--label-size: 0.72rem;
|
||||
--bt-size: 1.05rem;
|
||||
|
||||
/* prompt baseline */
|
||||
--line-thickness: 1px;
|
||||
}
|
||||
|
||||
/* ===================== PALETTES ===================== */
|
||||
|
||||
body[data-palette="rose"] {
|
||||
--bg: #f5ebe0;
|
||||
--bg-soft: #f8e8db;
|
||||
--surface: #f0d9c7;
|
||||
--ink: #3d2820;
|
||||
--ink-muted: #8b6f60;
|
||||
--ink-faint: #b89a87;
|
||||
--coral: #e07856;
|
||||
--red: #c54f3d;
|
||||
--line: #e8d4c0;
|
||||
}
|
||||
|
||||
body[data-palette="morning"] {
|
||||
--bg: #faf3e3;
|
||||
--bg-soft: #f4ead0;
|
||||
--surface: #ecdfb8;
|
||||
--ink: #3a2f1a;
|
||||
--ink-muted: #7a6a4a;
|
||||
--ink-faint: #b6a684;
|
||||
--coral: #d99b4a;
|
||||
--red: #b5703a;
|
||||
--line: #e7d6a8;
|
||||
}
|
||||
|
||||
body[data-palette="evening"] {
|
||||
--bg: #ece1de;
|
||||
--bg-soft: #e4d2cd;
|
||||
--surface: #d8bdb6;
|
||||
--ink: #2e1b1d;
|
||||
--ink-muted: #7d5a59;
|
||||
--ink-faint: #b08c8a;
|
||||
--coral: #b04a55;
|
||||
--red: #84313e;
|
||||
--line: #d6bbb6;
|
||||
}
|
||||
|
||||
body[data-palette="sage"] {
|
||||
--bg: #ecede4;
|
||||
--bg-soft: #e1e3d4;
|
||||
--surface: #cdd1bd;
|
||||
--ink: #25291e;
|
||||
--ink-muted: #5d6451;
|
||||
--ink-faint: #97a085;
|
||||
--coral: #6f8861;
|
||||
--red: #58683f;
|
||||
--line: #cfd4be;
|
||||
}
|
||||
|
||||
body[data-palette="paper"] {
|
||||
--bg: #f7f4ed;
|
||||
--bg-soft: #efeada;
|
||||
--surface: #e1d9c2;
|
||||
--ink: #1d1c19;
|
||||
--ink-muted: #6b6759;
|
||||
--ink-faint: #a8a292;
|
||||
--coral: #8b5d2f;
|
||||
--red: #6a3f1b;
|
||||
--line: #d8cfb6;
|
||||
}
|
||||
|
||||
body[data-palette="ink"] {
|
||||
--bg: #1d1d1f;
|
||||
--bg-soft: #26262a;
|
||||
--surface: #2f2f34;
|
||||
--ink: #e8e6df;
|
||||
--ink-muted: #9d9a90;
|
||||
--ink-faint: #65635c;
|
||||
--coral: #d8a572;
|
||||
--red: #b87653;
|
||||
--line: #353539;
|
||||
}
|
||||
|
||||
/* ===================== TYPOGRAPHY SETS ===================== */
|
||||
|
||||
body[data-typography="sans"] {
|
||||
--font-body: var(--sans);
|
||||
--font-label: var(--sans);
|
||||
}
|
||||
body[data-typography="serif-warm"] {
|
||||
--font-body: var(--serif-warm);
|
||||
--font-label: var(--hand);
|
||||
--bt-size: 1.25rem;
|
||||
}
|
||||
body[data-typography="serif-formal"] {
|
||||
--font-body: var(--serif);
|
||||
--font-label: var(--sans);
|
||||
--bt-size: 1.15rem;
|
||||
}
|
||||
body[data-typography="mixed-modern"] {
|
||||
--font-body: var(--sans);
|
||||
--font-label: var(--hand);
|
||||
}
|
||||
body[data-typography="mono"] {
|
||||
--font-body: var(--mono);
|
||||
--font-label: var(--mono);
|
||||
--bt-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ===================== DENSITY ===================== */
|
||||
body[data-density="airy"] { --msg-gap: 2.3rem; --max-col: 38rem; --body-size: 1.05rem; }
|
||||
body[data-density="normal"] { --msg-gap: 1.5rem; --max-col: 36rem; --body-size: 1rem; }
|
||||
body[data-density="dense"] { --msg-gap: 1rem; --max-col: 34rem; --body-size: 0.95rem; }
|
||||
|
||||
/* ===================== RESET ===================== */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===================== LAYOUT ===================== */
|
||||
.page {
|
||||
max-width: var(--max-col);
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 0 1.5rem;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- top-nav strip (recalibrate · sign out · sigil) ---------- */
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
z-index: 10;
|
||||
}
|
||||
.topnav__link {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
transition: opacity 400ms ease, color 400ms ease;
|
||||
}
|
||||
.topnav__link:hover,
|
||||
.topnav__link:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
.topnav__link:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: progress;
|
||||
}
|
||||
.topnav__sep {
|
||||
color: var(--ink-faint);
|
||||
opacity: 0.35;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.sigil {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.35;
|
||||
transition: opacity 600ms ease;
|
||||
user-select: none;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
.sigil:hover { opacity: 0.85; }
|
||||
.sigil img { width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* ===================== CONVERSATION ===================== */
|
||||
.conversation {
|
||||
flex: 1 0 auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: var(--msg-gap) 0;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
animation: appear 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.msg__label {
|
||||
font-family: var(--font-label);
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--label-size);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.45rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Cursive label override */
|
||||
body[data-labels="cursive"] .msg__label {
|
||||
font-family: var(--hand);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: none;
|
||||
}
|
||||
body[data-labels="none"] .msg__label { display: none; }
|
||||
body[data-labels="prefix"] .msg__label {
|
||||
display: inline;
|
||||
margin-right: 0.5em;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.msg__body {
|
||||
color: var(--ink);
|
||||
font-size: var(--bt-size);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.msg--user .msg__body {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.msg--system .msg__body {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.74rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-family: var(--sans);
|
||||
font-weight: 300;
|
||||
}
|
||||
.msg--system .msg__label { display: none; }
|
||||
|
||||
.msg--calibration .msg__body {
|
||||
color: var(--ink);
|
||||
font-size: calc(var(--bt-size) * 1.02);
|
||||
font-style: italic;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.msg--calibration .msg__label { display: none; }
|
||||
.msg--calibration { margin: 1.5rem 0; }
|
||||
|
||||
/* repeat-label hide */
|
||||
.msg[data-hide-label="true"] .msg__label { display: none; }
|
||||
.msg[data-hide-label="true"] { margin-top: 0.8rem; }
|
||||
|
||||
/* ===================== THINKING ===================== */
|
||||
.thinking {
|
||||
color: var(--coral);
|
||||
font-size: 0.65rem;
|
||||
margin: 1.5rem 0;
|
||||
user-select: none;
|
||||
animation: pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.thinking::before { content: "●"; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.30; }
|
||||
50% { opacity: 1.0; }
|
||||
}
|
||||
@keyframes appear {
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* typewriter cursor */
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: 0.5ch;
|
||||
margin-left: 0.05ch;
|
||||
color: var(--coral);
|
||||
animation: caret 800ms ease-out forwards;
|
||||
}
|
||||
.caret::after { content: "▍"; }
|
||||
@keyframes caret {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ===================== PROMPT ===================== */
|
||||
.prompt {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bg);
|
||||
padding: 1.5rem 0 2.5rem 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.prompt__line {
|
||||
border: none;
|
||||
border-top: var(--line-thickness) solid var(--line);
|
||||
margin: 0 0 0.85rem 0;
|
||||
}
|
||||
.prompt__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.prompt__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
font-size: var(--body-size);
|
||||
color: var(--ink);
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.prompt__input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
.prompt__input:focus { caret-color: var(--coral); }
|
||||
|
||||
/* ===================== MIC ===================== */
|
||||
.prompt__mic {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--ink-faint);
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: color 200ms ease, background 200ms ease, transform 200ms ease;
|
||||
}
|
||||
.prompt__mic:hover { color: var(--ink-muted); }
|
||||
.prompt__mic:active { transform: scale(0.95); }
|
||||
.prompt__mic.recording {
|
||||
color: var(--coral);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
animation: mic-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.prompt__mic.transcribing {
|
||||
color: var(--gold);
|
||||
animation: mic-spin 1.2s linear infinite;
|
||||
}
|
||||
.prompt__mic.empty {
|
||||
color: var(--red);
|
||||
animation: mic-empty 0.7s ease;
|
||||
}
|
||||
@keyframes mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.18); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(0, 0, 0, 0); }
|
||||
}
|
||||
@keyframes mic-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes mic-empty {
|
||||
0% { transform: translateX(0); }
|
||||
20% { transform: translateX(-2px); }
|
||||
40% { transform: translateX(2px); }
|
||||
60% { transform: translateX(-2px); }
|
||||
80% { transform: translateX(2px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ===================== CHOICE TILES ===================== */
|
||||
.choices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin: 1rem 0 0.25rem 0;
|
||||
}
|
||||
.choice {
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
font-style: normal;
|
||||
padding: 0.5rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
transition: background 220ms ease, color 220ms ease,
|
||||
border-color 220ms ease, transform 220ms ease, opacity 220ms ease;
|
||||
}
|
||||
.choice:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--coral);
|
||||
}
|
||||
.choice:active { transform: scale(0.97); }
|
||||
.choice:disabled { cursor: default; opacity: 0.45; }
|
||||
.choice--selected {
|
||||
background: var(--coral);
|
||||
color: var(--bg);
|
||||
border-color: var(--coral);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.choice--other {
|
||||
font-style: italic;
|
||||
color: var(--ink-muted);
|
||||
background: transparent;
|
||||
}
|
||||
.choice__icon { font-size: 1.05rem; line-height: 1; display: inline-flex; }
|
||||
.choice__label { line-height: 1; }
|
||||
|
||||
/* ===================== DENIED PAGE ===================== */
|
||||
.denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.denied__title {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.7rem;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.denied__body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
color: var(--ink-muted);
|
||||
max-width: 28rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.denied__link {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--red);
|
||||
text-decoration: none;
|
||||
margin-top: 3rem;
|
||||
display: inline-block;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
.denied__link:hover { border-color: var(--red); }
|
||||
@@ -0,0 +1,417 @@
|
||||
/* chat.saiden.dev — frontend
|
||||
*
|
||||
* Minimal vanilla JS. WS connects to /ws, sends {content}, receives
|
||||
* {role, delta, done} chunks. Streams into a typewriter queue.
|
||||
*
|
||||
* Design rules (see UI-PLAN.md):
|
||||
* - Speaker labels only appear when speaker changes from the previous message.
|
||||
* - Pilot's outbound messages render instantly. BT's stream via typewriter.
|
||||
* - One coral pulsing dot while waiting. Cursor fades when stream completes.
|
||||
*/
|
||||
|
||||
const TYPEWRITER_MS = 22; // ~45 chars/sec
|
||||
const SCROLL_EASE_MS = 600;
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $conversation = $('#conversation');
|
||||
const $form = $('#prompt-form');
|
||||
const $input = $('#prompt-input');
|
||||
|
||||
let ws = null;
|
||||
let connectAttempts = 0;
|
||||
let lastSpeaker = null; // 'user' | 'bt' | 'system' | null
|
||||
let currentBtBody = null; // active streaming .msg__body element
|
||||
let queue = [];
|
||||
let draining = false;
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function speakerLabel(role) {
|
||||
if (role === 'user') return window.__pilotName || 'Pilot';
|
||||
if (role === 'bt') return window.__personaName || 'BT';
|
||||
if (role === 'calibration') return '—';
|
||||
return 'channel';
|
||||
}
|
||||
|
||||
function makeMsg(role) {
|
||||
const repeat = role === lastSpeaker;
|
||||
const msg = document.createElement('article');
|
||||
msg.className = `msg msg--${role}`;
|
||||
if (repeat) msg.setAttribute('data-hide-label', 'true');
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'msg__label';
|
||||
label.textContent = speakerLabel(role);
|
||||
msg.appendChild(label);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'msg__body';
|
||||
msg.appendChild(body);
|
||||
|
||||
$conversation.appendChild(msg);
|
||||
lastSpeaker = role;
|
||||
scrollToBottom();
|
||||
return body;
|
||||
}
|
||||
|
||||
function smoothScrollTo(target) {
|
||||
const start = window.scrollY;
|
||||
const dist = target - start;
|
||||
const t0 = performance.now();
|
||||
function step(now) {
|
||||
const t = Math.min(1, (now - t0) / SCROLL_EASE_MS);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
window.scrollTo(0, start + dist * eased);
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const target = document.documentElement.scrollHeight - window.innerHeight;
|
||||
smoothScrollTo(target);
|
||||
}
|
||||
|
||||
function showThinking() {
|
||||
removeThinking();
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'thinking';
|
||||
dot.id = 'thinking';
|
||||
$conversation.appendChild(dot);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function removeThinking() {
|
||||
const t = document.getElementById('thinking');
|
||||
if (t) t.remove();
|
||||
}
|
||||
|
||||
// ---------- typewriter ----------
|
||||
|
||||
function enqueue(text) {
|
||||
for (const ch of text) queue.push(ch);
|
||||
if (!draining) drain();
|
||||
}
|
||||
|
||||
async function drain() {
|
||||
if (!currentBtBody) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
currentBtBody.textContent += queue.shift();
|
||||
if (Math.random() < 0.04) scrollToBottom();
|
||||
await sleep(TYPEWRITER_MS);
|
||||
}
|
||||
draining = false;
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function finishBt() {
|
||||
// wait for the queue to drain before adding caret
|
||||
const tick = () => {
|
||||
if (draining || queue.length) { setTimeout(tick, 30); return; }
|
||||
if (!currentBtBody) return;
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'caret';
|
||||
currentBtBody.appendChild(caret);
|
||||
scrollToBottom();
|
||||
setTimeout(() => caret.remove(), 900);
|
||||
currentBtBody = null;
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
// ---------- ws ----------
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
connectAttempts = 0;
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (e) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(e.data); } catch { return; }
|
||||
handleMessage(msg);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', (e) => {
|
||||
if (e.code === 4401) {
|
||||
// session expired — say so softly, but DO NOT navigate the page away.
|
||||
// The Pilot decides when to refresh.
|
||||
const body = makeMsg('system');
|
||||
body.textContent = 'session lost — refresh when ready';
|
||||
return;
|
||||
}
|
||||
// try a gentle reconnect, with a hard cap so we don't spam forever
|
||||
connectAttempts++;
|
||||
if (connectAttempts > 6) return;
|
||||
const delay = Math.min(8000, 600 * connectAttempts);
|
||||
setTimeout(connect, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
if (msg.role === 'system') {
|
||||
const body = makeMsg('system');
|
||||
body.textContent = msg.content || '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.role === 'calibration') {
|
||||
removeThinking();
|
||||
const body = makeMsg('calibration');
|
||||
body.textContent = msg.content || '';
|
||||
if (Array.isArray(msg.choices) && msg.choices.length) {
|
||||
renderChoices(body.parentElement, msg.choices);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.role === 'calibration_done') {
|
||||
// Transition in place — no reload.
|
||||
if (msg.persona_name) window.__personaName = msg.persona_name;
|
||||
if (msg.operator_name) window.__pilotName = msg.operator_name;
|
||||
if (msg.ui_palette) document.body.setAttribute('data-palette', msg.ui_palette);
|
||||
if (msg.ui_typography) document.body.setAttribute('data-typography', msg.ui_typography);
|
||||
if (msg.ui_density) document.body.setAttribute('data-density', msg.ui_density);
|
||||
if (msg.ui_labels) document.body.setAttribute('data-labels', msg.ui_labels);
|
||||
if (msg.persona_name) {
|
||||
$input.setAttribute('placeholder', `speak to ${msg.persona_name}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
if (!currentBtBody) {
|
||||
removeThinking();
|
||||
currentBtBody = makeMsg('bt');
|
||||
}
|
||||
if (msg.delta) enqueue(msg.delta);
|
||||
if (msg.done) finishBt();
|
||||
}
|
||||
|
||||
if (msg.role === 'audio' && msg.data) {
|
||||
playAudio(msg.mime || 'audio/wav', msg.data);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- audio ----------
|
||||
|
||||
let currentAudio = null;
|
||||
|
||||
function playAudio(mime, b64) {
|
||||
// stop any in-flight playback first — last word wins
|
||||
if (currentAudio) {
|
||||
try { currentAudio.pause(); } catch {}
|
||||
currentAudio = null;
|
||||
}
|
||||
const audio = new Audio(`data:${mime};base64,${b64}`);
|
||||
audio.volume = 0.9;
|
||||
audio.play().catch(err => {
|
||||
// autoplay may be blocked until first user gesture — gracefully degrade
|
||||
console.warn('audio autoplay blocked:', err.message);
|
||||
});
|
||||
currentAudio = audio;
|
||||
}
|
||||
|
||||
// ---------- form ----------
|
||||
|
||||
$form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const text = $input.value.trim();
|
||||
if (!text) return;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const body = makeMsg('user');
|
||||
body.textContent = text;
|
||||
|
||||
ws.send(JSON.stringify({ content: text }));
|
||||
$input.value = '';
|
||||
showThinking();
|
||||
});
|
||||
|
||||
// Cmd+K / Ctrl+K to refocus input
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
$input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- voice input (whisper) ----------
|
||||
|
||||
const $mic = document.getElementById('mic-button');
|
||||
let mediaRecorder = null;
|
||||
let recordedChunks = [];
|
||||
let recording = false;
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
recordedChunks = [];
|
||||
// Prefer webm/opus if browser supports it (Chrome/FF). Safari may need fallback.
|
||||
const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', ''];
|
||||
const mime = mimeTypes.find(m => !m || MediaRecorder.isTypeSupported(m)) || '';
|
||||
mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||
mediaRecorder.addEventListener('dataavailable', (e) => {
|
||||
if (e.data && e.data.size > 0) recordedChunks.push(e.data);
|
||||
});
|
||||
mediaRecorder.addEventListener('stop', () => {
|
||||
// release the mic immediately
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
handleRecorded();
|
||||
});
|
||||
mediaRecorder.start();
|
||||
recording = true;
|
||||
$mic.classList.add('recording');
|
||||
} catch (err) {
|
||||
console.warn('microphone unavailable:', err.message);
|
||||
recording = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
||||
mediaRecorder.stop();
|
||||
recording = false;
|
||||
$mic.classList.remove('recording');
|
||||
$mic.classList.add('transcribing');
|
||||
}
|
||||
|
||||
async function handleRecorded() {
|
||||
if (!recordedChunks.length) {
|
||||
$mic.classList.remove('transcribing');
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(recordedChunks, { type: recordedChunks[0].type || 'audio/webm' });
|
||||
const ext = (blob.type.split('/')[1] || 'webm').split(';')[0];
|
||||
const form = new FormData();
|
||||
form.append('audio', blob, `speech.${ext}`);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/transcribe', { method: 'POST', body: form });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const { text } = await resp.json();
|
||||
if (text && text.trim()) {
|
||||
$input.value = text;
|
||||
// auto-send — Her vibe is "speak and she hears"
|
||||
$form.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||
} else {
|
||||
flashMicEmpty();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('transcription failed:', err.message);
|
||||
flashMicEmpty();
|
||||
} finally {
|
||||
$mic.classList.remove('transcribing');
|
||||
}
|
||||
}
|
||||
|
||||
function flashMicEmpty() {
|
||||
// brief visual hint that nothing was heard — no toast, no popup, just a flash
|
||||
$mic.classList.add('empty');
|
||||
setTimeout(() => $mic.classList.remove('empty'), 700);
|
||||
}
|
||||
|
||||
// ---------- calibration choice tiles ----------
|
||||
|
||||
function renderChoices(parentEl, choices) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'choices';
|
||||
for (const c of choices) {
|
||||
const tile = document.createElement('button');
|
||||
tile.type = 'button';
|
||||
tile.className = 'choice';
|
||||
if (c.value === '__other__') tile.classList.add('choice--other');
|
||||
|
||||
if (c.icon) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'choice__icon';
|
||||
icon.textContent = c.icon;
|
||||
tile.appendChild(icon);
|
||||
}
|
||||
const label = document.createElement('span');
|
||||
label.className = 'choice__label';
|
||||
label.textContent = c.label;
|
||||
tile.appendChild(label);
|
||||
|
||||
tile.addEventListener('click', () => {
|
||||
// disable all tiles in this group
|
||||
wrap.querySelectorAll('.choice').forEach(b => b.disabled = true);
|
||||
tile.classList.add('choice--selected');
|
||||
|
||||
if (c.value === '__other__') {
|
||||
// remove tiles, focus input so the operator types freely
|
||||
wrap.remove();
|
||||
$input.focus();
|
||||
return;
|
||||
}
|
||||
sendChoice(c.label, c.value);
|
||||
// collapse tiles into a faint chosen tag
|
||||
setTimeout(() => wrap.remove(), 200);
|
||||
});
|
||||
|
||||
wrap.appendChild(tile);
|
||||
}
|
||||
parentEl.appendChild(wrap);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function sendChoice(label, value) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
// Render as a "Pilot" message using the human-readable label
|
||||
const body = makeMsg('user');
|
||||
body.textContent = label;
|
||||
// Send the machine value to the server
|
||||
ws.send(JSON.stringify({ content: value }));
|
||||
showThinking();
|
||||
}
|
||||
|
||||
function toggleMic() {
|
||||
if (recording) stopRecording();
|
||||
else startRecording();
|
||||
}
|
||||
|
||||
$mic.addEventListener('click', toggleMic);
|
||||
|
||||
// Hold-space-to-talk when input is NOT focused (so typing space still works)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code !== 'Space') return;
|
||||
if (document.activeElement === $input) return;
|
||||
if (e.repeat) return;
|
||||
e.preventDefault();
|
||||
if (!recording) startRecording();
|
||||
});
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.code !== 'Space') return;
|
||||
if (recording) stopRecording();
|
||||
});
|
||||
|
||||
// ---------- recalibrate ----------
|
||||
|
||||
const $recal = document.getElementById('recalibrate-btn');
|
||||
if ($recal) {
|
||||
$recal.addEventListener('click', async () => {
|
||||
if ($recal.disabled) return;
|
||||
$recal.disabled = true;
|
||||
$recal.textContent = 'resetting…';
|
||||
try {
|
||||
const resp = await fetch('/api/recalibrate', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
// explicit reset is user-driven — reload is appropriate here
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
console.warn('recalibrate failed:', err.message);
|
||||
$recal.textContent = 'recalibrate';
|
||||
$recal.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- init ----------
|
||||
|
||||
connect();
|
||||
$input.focus();
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"""Whisper.cpp STT adapter for chat.saiden.dev.
|
||||
|
||||
Transcribes microphone audio (webm/opus from browser) → text.
|
||||
Pipeline: ffmpeg → 16kHz mono WAV → whisper-cli → stdout text.
|
||||
|
||||
Fails silently if the binary or model is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Whisper.cpp special tokens — emitted for non-speech audio.
|
||||
# If the entire transcript is one of these, treat as no speech.
|
||||
_NON_SPEECH = re.compile(
|
||||
r"^\s*[\[\(](?:BLANK_AUDIO|INAUDIBLE|NO_SPEECH|MUSIC|NOISE|SILENCE|SOUND|"
|
||||
r"APPLAUSE|LAUGHTER|CROSSTALK|BREATHING|UNINTELLIGIBLE)[\]\)]\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
log = logging.getLogger("chat-saiden.stt")
|
||||
|
||||
WHISPER_BIN = shutil.which("whisper-cli") or os.environ.get("WHISPER_BIN")
|
||||
FFMPEG_BIN = shutil.which("ffmpeg") or os.environ.get("FFMPEG_BIN")
|
||||
|
||||
_MODEL_SEARCH = [
|
||||
Path.home() / ".cache/whisper/ggml-base.en.bin",
|
||||
Path.home() / ".cache/whisper/ggml-small.en.bin",
|
||||
Path.home() / ".cache/whisper/ggml-tiny.en.bin",
|
||||
Path("/usr/local/share/whisper.cpp/ggml-base.en.bin"),
|
||||
Path("/usr/share/whisper.cpp/ggml-base.en.bin"),
|
||||
]
|
||||
|
||||
|
||||
def _resolve_model() -> Path | None:
|
||||
override = os.environ.get("WHISPER_MODEL_PATH")
|
||||
if override:
|
||||
p = Path(override)
|
||||
return p if p.exists() else None
|
||||
for cand in _MODEL_SEARCH:
|
||||
if cand.exists():
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
class STT:
|
||||
"""Whisper-cpp wrapper."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.bin = WHISPER_BIN
|
||||
self.ffmpeg = FFMPEG_BIN
|
||||
self.model = _resolve_model()
|
||||
if not self.bin:
|
||||
log.warning("whisper-cli not found — STT disabled")
|
||||
elif not self.ffmpeg:
|
||||
log.warning("ffmpeg not found — STT disabled")
|
||||
elif not self.model:
|
||||
log.warning("no whisper model in known locations — STT disabled")
|
||||
else:
|
||||
log.info("STT enabled — model=%s bin=%s", self.model, self.bin)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return bool(self.bin and self.ffmpeg and self.model)
|
||||
|
||||
async def transcribe(self, audio_bytes: bytes, suffix: str = ".webm") -> str | None:
|
||||
"""Return transcript text, or None on failure / unavailable."""
|
||||
if not self.available:
|
||||
return None
|
||||
if not audio_bytes:
|
||||
return None
|
||||
|
||||
tmp_in = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp_in.write(audio_bytes)
|
||||
tmp_in.close()
|
||||
|
||||
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
tmp_wav.close()
|
||||
|
||||
try:
|
||||
# 1. ffmpeg: convert to 16kHz mono WAV (whisper's expected format)
|
||||
ff = await asyncio.create_subprocess_exec(
|
||||
self.ffmpeg, "-y", "-loglevel", "error",
|
||||
"-i", tmp_in.name,
|
||||
"-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le",
|
||||
tmp_wav.name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, ff_err = await ff.communicate()
|
||||
if ff.returncode != 0:
|
||||
log.error("ffmpeg failed: %s", ff_err.decode("utf-8", "replace")[:300])
|
||||
return None
|
||||
|
||||
# 2. whisper-cli: transcribe → plain text on stdout
|
||||
wh = await asyncio.create_subprocess_exec(
|
||||
self.bin,
|
||||
"-m", str(self.model),
|
||||
"-f", tmp_wav.name,
|
||||
"--no-timestamps",
|
||||
"--no-prints",
|
||||
"--output-txt",
|
||||
"-of", tmp_wav.name + ".out",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, wh_err = await wh.communicate()
|
||||
if wh.returncode != 0:
|
||||
log.error("whisper failed: %s", wh_err.decode("utf-8", "replace")[:300])
|
||||
return None
|
||||
|
||||
txt_path = Path(tmp_wav.name + ".out.txt")
|
||||
if not txt_path.exists():
|
||||
log.error("whisper produced no output file")
|
||||
return None
|
||||
text = txt_path.read_text(encoding="utf-8").strip()
|
||||
try:
|
||||
txt_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if not text:
|
||||
return None
|
||||
# filter non-speech markers — whisper.cpp emits "[BLANK_AUDIO]" etc.
|
||||
if _NON_SPEECH.match(text):
|
||||
log.info("transcript was non-speech marker: %r", text)
|
||||
return None
|
||||
return text
|
||||
|
||||
except Exception:
|
||||
log.exception("transcribe failed")
|
||||
return None
|
||||
finally:
|
||||
for p in (tmp_in.name, tmp_wav.name):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>chat.saiden.dev</title>
|
||||
<meta name="description" content="A quiet channel.">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<link rel="icon" href="https://saiden.dev/favicon.ico">
|
||||
|
||||
<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=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Caveat:wght@400;500&family=Inter:wght@300;400;500;600&family=Source+Serif+Pro:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
|
||||
<link rel="stylesheet" href="/static/chat.css">
|
||||
</head>
|
||||
<body
|
||||
data-palette="{{ ui_palette }}"
|
||||
data-typography="{{ ui_typography }}"
|
||||
data-density="{{ ui_density }}"
|
||||
data-labels="{{ ui_labels }}">
|
||||
<script>
|
||||
window.__pilotName = {{ pilot_name | tojson }};
|
||||
window.__personaName = {{ persona_name | tojson }};
|
||||
</script>
|
||||
<nav class="topnav" aria-label="channel controls">
|
||||
<button type="button" class="topnav__link" id="recalibrate-btn" title="reset calibration">recalibrate</button>
|
||||
<span class="topnav__sep">·</span>
|
||||
<a class="topnav__link" href="/auth/logout">sign out</a>
|
||||
<a class="sigil" href="https://saiden.dev/" target="_blank" rel="noopener" title="Saiden">
|
||||
<img src="https://saiden.dev/logo.png" alt="Saiden">
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="page">
|
||||
<section class="conversation" id="conversation" aria-live="polite">
|
||||
</section>
|
||||
|
||||
<form class="prompt" id="prompt-form" autocomplete="off">
|
||||
<hr class="prompt__line">
|
||||
<div class="prompt__row">
|
||||
<input
|
||||
class="prompt__input"
|
||||
id="prompt-input"
|
||||
type="text"
|
||||
placeholder="{% if cart %}speak to {{ persona_name }}{% else %}…{% endif %}"
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
spellcheck="true"
|
||||
aria-label="message">
|
||||
<button type="button" class="prompt__mic" id="mic-button" aria-label="speak"
|
||||
title="hold space to speak · click to toggle">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="3" width="6" height="12" rx="3"></rect>
|
||||
<path d="M5 11a7 7 0 0 0 14 0"></path>
|
||||
<line x1="12" y1="18" x2="12" y2="22"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script src="/static/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>chat.saiden.dev — not authorised</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<link rel="icon" href="https://saiden.dev/favicon.ico">
|
||||
|
||||
<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=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Caveat:wght@400&family=Inter:wght@300&display=swap">
|
||||
|
||||
<link rel="stylesheet" href="/static/chat.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="denied">
|
||||
<h1 class="denied__title">not on the channel</h1>
|
||||
<p class="denied__body">{{ reason }}</p>
|
||||
<div class="denied__hand">— Saiden</div>
|
||||
<a class="denied__link" href="/auth/login">try again</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
"""Piper TTS adapter for chat.saiden.dev.
|
||||
|
||||
Synthesises text → WAV bytes by subprocess'ing the `piper` CLI binary
|
||||
(already installed on every host that runs marauder-os).
|
||||
|
||||
Designed to fail silently — if piper is missing or synthesis errors,
|
||||
the chat still works, just without voice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("chat-saiden.tts")
|
||||
|
||||
# Where the voice .onnx files live across hosts.
|
||||
# Order: env override → macOS marauder → linux marauder → linux marauder-agent (mesh node) → linux ~/.local
|
||||
_VOICE_SEARCH_PATHS = [
|
||||
Path.home() / "Library/Application Support/marauder/voices",
|
||||
Path("/home") / os.environ.get("USER", "marauder") / ".local/share/marauder/voices",
|
||||
Path.home() / ".local/share/marauder/voices",
|
||||
Path.home() / ".local/share/psn/voices",
|
||||
Path.home() / ".local/share/piper/voices",
|
||||
]
|
||||
|
||||
|
||||
def _resolve_voice_path(name: str) -> Path | None:
|
||||
"""Return absolute path to a voice model by short name, or None."""
|
||||
# explicit override
|
||||
override = os.environ.get("TTS_VOICE_PATH")
|
||||
if override:
|
||||
p = Path(override)
|
||||
return p if p.exists() else None
|
||||
|
||||
for base in _VOICE_SEARCH_PATHS:
|
||||
candidate = base / f"{name}.onnx"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
PIPER_BIN = shutil.which("piper") or os.environ.get("PIPER_BIN")
|
||||
|
||||
|
||||
class TTS:
|
||||
"""Subprocess-based piper synthesizer with graceful fallback."""
|
||||
|
||||
def __init__(self, voice: str = "en_US-amy-medium") -> None:
|
||||
self.voice = voice
|
||||
self.voice_path = _resolve_voice_path(voice)
|
||||
self.bin = PIPER_BIN
|
||||
if not self.bin:
|
||||
log.warning("piper binary not found on PATH — TTS disabled")
|
||||
elif not self.voice_path:
|
||||
log.warning("voice '%s' not found in known locations — TTS disabled", voice)
|
||||
else:
|
||||
log.info("TTS enabled — voice=%s path=%s bin=%s", voice, self.voice_path, self.bin)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return bool(self.bin and self.voice_path)
|
||||
|
||||
async def synthesize(self, text: str) -> bytes | None:
|
||||
"""Return WAV bytes, or None on failure / unavailable."""
|
||||
if not self.available:
|
||||
return None
|
||||
if not text.strip():
|
||||
return None
|
||||
|
||||
# piper wants an output file path (no stdout streaming for WAV in older versions)
|
||||
out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
out.close()
|
||||
out_path = out.name
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
self.bin,
|
||||
"--model", str(self.voice_path),
|
||||
"--output_file", out_path,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate(text.encode("utf-8"))
|
||||
if proc.returncode != 0:
|
||||
log.error("piper exited %s: %s", proc.returncode, stderr.decode("utf-8", "replace")[:300])
|
||||
return None
|
||||
with open(out_path, "rb") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
log.exception("piper synthesis failed")
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
os.unlink(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
Reference in New Issue
Block a user