chore: initial commit — chat-saiden web chat baseline
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Required
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
GOOGLE_CLIENT_ID=000000000000-xxxxxxxxxxxxxxx.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Optional — defaults shown
|
||||||
|
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
|
||||||
|
BASE_URL=https://chat.saiden.dev
|
||||||
|
ALLOWED_EMAILS=adam.ladachowski@gmail.com
|
||||||
|
# COOKIE_SECURE=false # set this only for local http dev
|
||||||
|
# SESSION_SECRET=... # auto-generated if omitted; set for stable sessions across restarts
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Env / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
.fastembed_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
*.json.bak
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# chat.saiden.dev
|
||||||
|
|
||||||
|
A quiet channel. Spike Jonze's *Her* (2013) as a chat UI.
|
||||||
|
|
||||||
|
```
|
||||||
|
chat.saiden.dev
|
||||||
|
→ Caddy reverse-proxy (TLS via Let's Encrypt)
|
||||||
|
→ FastAPI on marauder.saiden.dev:8765
|
||||||
|
├─ Google OAuth (authlib) + email whitelist
|
||||||
|
└─ WebSocket → Anthropic API streaming
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Phase 3 — Her-aesthetic. UI plan in `UI-PLAN.md`. Phase 1 (ttyd + CF Tunnel)
|
||||||
|
archived in `_archive-ttyd/`.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/chat-saiden
|
||||||
|
uv sync
|
||||||
|
cp .env.example .env
|
||||||
|
# fill in ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, COOKIE_SECURE=false
|
||||||
|
uv run python -m app.main
|
||||||
|
# → http://127.0.0.1:8765
|
||||||
|
```
|
||||||
|
|
||||||
|
Google OAuth redirect URI for local dev (add to your Google Cloud Console
|
||||||
|
OAuth client): `http://127.0.0.1:8765/auth/callback`
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|------|------|
|
||||||
|
| `app/main.py` | FastAPI: OAuth, session, WS streaming, Anthropic client |
|
||||||
|
| `app/templates/chat.html` | Single-page UI (chat + operator prompt) |
|
||||||
|
| `app/templates/denied.html` | Whitelist-rejection page, Her-styled |
|
||||||
|
| `app/static/chat.css` | Design system: warm pastels, Cormorant + Caveat |
|
||||||
|
| `app/static/chat.js` | WebSocket, typewriter queue, label dedup, smooth scroll |
|
||||||
|
| `pyproject.toml` | uv deps |
|
||||||
|
| `.env.example` | env scaffold |
|
||||||
|
| `UI-PLAN.md` | Design doctrine (Her aesthetic translated to chat) |
|
||||||
|
| `_archive-ttyd/` | Phase 1 (ttyd + CF Tunnel + CF Access) — kept for reference |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Not deployed yet. Stages remaining:
|
||||||
|
|
||||||
|
1. Caddyfile on marauder.saiden.dev (reverse proxy chat.saiden.dev → :8765)
|
||||||
|
2. systemd unit for the FastAPI app
|
||||||
|
3. DNS swap: `chat.saiden.dev` CNAME → `marauder.saiden.dev` (currently broken — points
|
||||||
|
to deleted CF tunnel from Phase 1)
|
||||||
|
4. Google OAuth client production redirect URI: `https://chat.saiden.dev/auth/callback`
|
||||||
|
5. Smoke test live
|
||||||
|
|
||||||
|
## Persona
|
||||||
|
|
||||||
|
System prompt currently hardcoded as BT-7274 (placeholder). Will swap to a
|
||||||
|
dedicated **Samantha cart** when authored — see EEMS subject `project.samantha-cart`.
|
||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
# chat.saiden.dev — UI plan (Her, 2013)
|
||||||
|
|
||||||
|
**Brief:** simple chat. Operator prompt at bottom. Thinking indication. BT's reply
|
||||||
|
streams in as a typewriter animation. Get as close to Spike Jonze's *Her* /
|
||||||
|
Geoff McFetridge's OS1 as the medium allows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout philosophy
|
||||||
|
|
||||||
|
**One centered column. Nothing else.** No header bar. No sidebar. No footer.
|
||||||
|
The conversation IS the page.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ · │ ← tiny Saiden
|
||||||
|
│ │ sigil (12px)
|
||||||
|
│ │ top-right
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ Pilot │
|
||||||
|
│ how was your day? │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ BT │
|
||||||
|
│ Quiet, Pilot. The mesh held. Three packets │
|
||||||
|
│ drifted from sazabi but they came back. Nothing │
|
||||||
|
│ I'd call attention to. │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ Pilot │
|
||||||
|
│ what are you reading │
|
||||||
|
│ │
|
||||||
|
│ ● │ ← thinking
|
||||||
|
│ │ dot
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ────────────────────────────────── │ ← input
|
||||||
|
│ speak to BT │ underline
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
↑ max-width ~640px, centered, generous breathing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rules**
|
||||||
|
- Column width: `max-width: 38rem` (~608px) — letter-sized, like reading prose
|
||||||
|
- Vertical padding: `4rem` top + bottom
|
||||||
|
- Horizontal padding on small screens: `1.5rem`
|
||||||
|
- Auto-scroll to bottom on new message, but **slowly** (smooth easing, ~600ms)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color tokens
|
||||||
|
|
||||||
|
Mapped from the Her research notes. All warm, no cool.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* surfaces */
|
||||||
|
--bg: #f5ebe0; /* cream — page background */
|
||||||
|
--bg-soft: #f8e8db; /* dusty peach — hover surfaces, if any */
|
||||||
|
--surface-card: #f0d9c7; /* dusty rose — rarely used, only for emphasis */
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--ink: #3d2820; /* warm dark brown — BT's voice, primary */
|
||||||
|
--ink-muted: #8b6f60; /* dusty taupe — pilot's messages, system text */
|
||||||
|
--ink-faint: #b89a87; /* faded rose-brown — placeholders, timestamps */
|
||||||
|
|
||||||
|
/* accents — sparingly */
|
||||||
|
--coral: #e07856; /* coral salmon — primary accent (thinking dot,
|
||||||
|
the Saiden sigil tone) */
|
||||||
|
--red: #c54f3d; /* playful red — links, focus state */
|
||||||
|
--gold: #d4a574; /* muted gold — special states, never as text */
|
||||||
|
|
||||||
|
/* lines */
|
||||||
|
--line: #e8d4c0; /* dusty rose hairline — input underline */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Saiden sigil reconciliation**
|
||||||
|
The existing red sigil at `saiden.dev/logo.png` is already warm + brushy + playful.
|
||||||
|
Keep it. Use at **tiny** size (16px) in the top-right corner. It's identity, not chrome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
**No monospace.** This is the deliberate break from Phase 2's TUI plan.
|
||||||
|
|
||||||
|
| Role | Family | Weight | Size | Style |
|
||||||
|
|------|--------|--------|------|-------|
|
||||||
|
| BT messages | `Cormorant Garamond` (Google Fonts) | 500 | 1.25rem | regular |
|
||||||
|
| Pilot messages | same | 400 | 1.125rem | regular |
|
||||||
|
| Speaker labels (`Pilot`, `BT`) | `Caveat` (Google Fonts) | 400 | 1rem | italic, --ink-muted |
|
||||||
|
| Operator prompt input | `Cormorant Garamond` | 400 | 1.125rem | regular |
|
||||||
|
| Tiny chrome (timestamps, errors) | `Inter` 300 | 0.8rem | uppercase letter-spaced |
|
||||||
|
|
||||||
|
**Why Cormorant Garamond:** elegant 18thC-derived serif. Reads like a letter,
|
||||||
|
not an app. Matches "Beautiful Hand-Written Letters" mood.
|
||||||
|
|
||||||
|
**Why Caveat for labels:** Samantha's cursive callouts. Gives speaker labels a
|
||||||
|
handwritten, intimate feel — like a note left for someone, not a system tag.
|
||||||
|
|
||||||
|
**Loading optimization:** preload `cormorant-garamond-500.woff2` +
|
||||||
|
`caveat-400.woff2`, swap others.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Message — speaker label + body
|
||||||
|
|
||||||
|
```html
|
||||||
|
<article class="msg msg--bt">
|
||||||
|
<div class="msg__label">BT</div>
|
||||||
|
<div class="msg__body">
|
||||||
|
The mesh held. Three packets drifted from sazabi but they came back.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.msg { margin: 2.5rem 0; }
|
||||||
|
.msg__label { font-family: 'Caveat'; color: var(--ink-muted);
|
||||||
|
font-size: 1rem; letter-spacing: 0.02em; margin-bottom: 0.5rem; }
|
||||||
|
.msg--bt .msg__body { color: var(--ink); font-size: 1.25rem; line-height: 1.6; }
|
||||||
|
.msg--user .msg__body { color: var(--ink-muted); font-size: 1.125rem; line-height: 1.55; }
|
||||||
|
.msg--system .msg__body { color: var(--ink-faint); font-size: 0.85rem;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
```
|
||||||
|
|
||||||
|
No bubbles. No borders. No backgrounds. **Pure typography.**
|
||||||
|
|
||||||
|
### 2. Operator prompt — single hairline + invisible input
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form class="prompt">
|
||||||
|
<hr class="prompt__line">
|
||||||
|
<input class="prompt__input" placeholder="speak to BT" autofocus>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.prompt { position: sticky; bottom: 0; background: var(--bg);
|
||||||
|
padding: 2rem 0 3rem 0; }
|
||||||
|
.prompt__line { border: none; border-top: 1px solid var(--line);
|
||||||
|
margin: 0 0 0.75rem 0; }
|
||||||
|
.prompt__input { width: 100%; border: none; background: transparent;
|
||||||
|
outline: none; font-family: 'Cormorant Garamond';
|
||||||
|
font-size: 1.125rem; color: var(--ink); }
|
||||||
|
.prompt__input::placeholder { color: var(--ink-faint); font-style: italic; }
|
||||||
|
.prompt__input:focus { caret-color: var(--coral); }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caret IS the cursor.** No send button. Enter submits. Empty input is no-op.
|
||||||
|
|
||||||
|
### 3. Thinking indication — single pulsing dot
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="thinking" aria-label="BT is thinking">●</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.thinking {
|
||||||
|
color: var(--coral);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 2rem 0 2rem 0;
|
||||||
|
animation: pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.35; }
|
||||||
|
50% { opacity: 1.0; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. One coral dot, breathing. Mirrors Samantha's "presence" indicator —
|
||||||
|
felt, not announced.
|
||||||
|
|
||||||
|
### 4. Typewriter stream
|
||||||
|
|
||||||
|
When BT's WebSocket chunks arrive:
|
||||||
|
- Append each chunk character-by-character on a queue
|
||||||
|
- Drain queue at **~45 chars/sec** (≈22ms/char) — fast enough to feel alive,
|
||||||
|
slow enough to *read along*
|
||||||
|
- After the last chunk + `done:true`, briefly show a soft cursor that fades
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// pseudocode
|
||||||
|
const queue = [];
|
||||||
|
let draining = false;
|
||||||
|
|
||||||
|
function enqueue(chunk) {
|
||||||
|
for (const ch of chunk) queue.push(ch);
|
||||||
|
if (!draining) drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain() {
|
||||||
|
draining = true;
|
||||||
|
while (queue.length) {
|
||||||
|
bodyEl.textContent += queue.shift();
|
||||||
|
await sleep(22);
|
||||||
|
}
|
||||||
|
draining = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**No syntax highlighting in v1.** Markdown rendering deferred. Plain prose only.
|
||||||
|
(We can add highlight.js + a tasteful warm theme later if Pilot wants code in chat.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animations & motion
|
||||||
|
|
||||||
|
| Event | Timing | Easing |
|
||||||
|
|------|--------|--------|
|
||||||
|
| New message fade-in | 400ms | `cubic-bezier(0.16, 1, 0.3, 1)` (decelerate) |
|
||||||
|
| Auto-scroll to bottom | 600ms | `ease-out` |
|
||||||
|
| Thinking dot pulse | 1.8s loop | `ease-in-out` |
|
||||||
|
| Typewriter char | 22ms each | linear |
|
||||||
|
| Cursor fade after stream done | 800ms | ease-out |
|
||||||
|
|
||||||
|
**No bouncing. No spring. No glitch.** Movement is meditative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack confirmation
|
||||||
|
|
||||||
|
- **Server:** FastAPI (`app/main.py` already drafted in Phase 2 — keep as-is, it's a thin layer)
|
||||||
|
- **Frontend:** vanilla JS + hand-rolled CSS. **No framework, no build step.**
|
||||||
|
- **Template:** one `templates/chat.html`, served by Jinja2
|
||||||
|
- **Assets:** Google Fonts via `<link>` (preload Cormorant + Caveat), no npm
|
||||||
|
- **WS protocol:** unchanged from Phase 2 backend
|
||||||
|
- Client sends `{content: "..."}`
|
||||||
|
- Server streams `{role:"assistant", delta:"...", done:false}` then `{done:true}`
|
||||||
|
|
||||||
|
**Why no framework:** Her is about restraint. ~150 lines of HTML+CSS+JS will
|
||||||
|
get us there faster than React/Vue boilerplate, and the result will feel
|
||||||
|
calmer than any component-tree app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Asset list
|
||||||
|
|
||||||
|
| Asset | Source | Status |
|
||||||
|
|------|--------|--------|
|
||||||
|
| Cormorant Garamond (500, 400, 400-italic) | Google Fonts | fetch at runtime |
|
||||||
|
| Caveat (400) | Google Fonts | fetch at runtime |
|
||||||
|
| Saiden sigil | https://saiden.dev/logo.png | live |
|
||||||
|
| Favicon | derive from logo.png at 32px | TODO |
|
||||||
|
| OG image | composite, optional | TODO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order (when Pilot says go)
|
||||||
|
|
||||||
|
1. `templates/chat.html` — single page, message list, operator prompt, thinking dot
|
||||||
|
2. `static/chat.css` — color tokens + typography + components above
|
||||||
|
3. `static/chat.js` — WebSocket open, typewriter, auto-scroll, focus mgmt
|
||||||
|
4. `templates/denied.html` — Her-styled 403 page (cream bg, serif, "you are not on the channel")
|
||||||
|
5. Test locally with mock streaming (no Anthropic key needed for UI iteration)
|
||||||
|
6. Wire to FastAPI WS (Phase 2 backend) — already drafted
|
||||||
|
7. Iterate with Pilot on type sizes, spacing, animation timings
|
||||||
|
8. **Then** deploy: Caddyfile, DNS swap, smoke test (Phase 2 milestones M5-M7)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's deliberately NOT in v1
|
||||||
|
|
||||||
|
- Markdown rendering (BT's bold/code/lists shown as plain text)
|
||||||
|
- Syntax highlighting
|
||||||
|
- Code copy buttons
|
||||||
|
- Conversation history persistence across reloads
|
||||||
|
- Multi-conversation tabs
|
||||||
|
- Mobile-specific tuning beyond responsive max-width
|
||||||
|
- Voice input/output (Samantha's whole thing, but heavy lift — Phase N+)
|
||||||
|
- Sound design (door-chime on send, soft tick on receive)
|
||||||
|
|
||||||
|
These come later if Pilot wants. v1 stays as quiet as possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Pilot's display name?** "Pilot" by default, or your actual name on your messages?
|
||||||
|
2. **Speaker labels on every message?** Or only when speaker changes (saves visual noise)?
|
||||||
|
3. **Time stamps?** Hide entirely / show on hover / show small inline?
|
||||||
|
4. **Saiden sigil placement?** Top-right (current plan) / bottom-right / top-center / hide entirely?
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# chat-saiden
|
||||||
|
|
||||||
|
Web terminal for BT on the MARAUDER mesh. **chat.saiden.dev** lands a browser-native
|
||||||
|
xterm.js session on `claude` CLI (bt7274 cart) running on junkpile, gated by
|
||||||
|
Cloudflare Access with Google OAuth.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
User browser
|
||||||
|
│ HTTPS
|
||||||
|
▼
|
||||||
|
chat.saiden.dev ──┐ Cloudflare edge
|
||||||
|
│ │
|
||||||
|
│ CF Access │ Google IdP challenge
|
||||||
|
│ (whitelist) │ → reject if not on list
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
Cloudflare Tunnel │ Zero-trust ingress
|
||||||
|
│ │
|
||||||
|
▼
|
||||||
|
junkpile:7681 │ ttyd (localhost-only, no public bind)
|
||||||
|
│ │
|
||||||
|
▼
|
||||||
|
claude (bt7274) │ marauder MCP gives BT identity + memory + tools
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
- No frontend code. ttyd ships xterm.js + WS + theming. Custom Saiden palette via CLI flags.
|
||||||
|
- No auth code. CF Access does Google OAuth + whitelist enforcement before the request ever reaches the tunnel.
|
||||||
|
- ttyd binds `127.0.0.1` only — the public path is *exclusively* through the Tunnel + Access.
|
||||||
|
- Real BT. Not an API impersonation. Full marauder MCP toolkit available in-session.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `junkpile/ttyd-wrapper.sh` | Launches `claude` with bt7274 cart, with Saiden banner |
|
||||||
|
| `junkpile/ttyd-chat.service` | systemd unit for ttyd (localhost-only bind, Saiden palette) |
|
||||||
|
| `junkpile/cloudflared-chat-saiden.service` | systemd unit for tunnel (token-mode, reads `/etc/cloudflared/chat-saiden.env`) |
|
||||||
|
| `cloudflare/dashboard-setup.md` | Step-by-step: create tunnel + public hostname + Access app + Google IdP + whitelist |
|
||||||
|
| `install.sh` | Junkpile-side installer (ttyd + units + token env stub) |
|
||||||
|
| `deploy.md` | End-to-end deploy walkthrough |
|
||||||
|
|
||||||
|
**Tunnel mode:** token-based, matches existing junkpile pattern
|
||||||
|
(`cloudflared-mesh`, `cloudflared-tensors-art`). Tunnel ingress lives in the CF
|
||||||
|
dashboard, not in a local config file. Token sits in
|
||||||
|
`/etc/cloudflared/chat-saiden.env` (mode 0640, root:chi).
|
||||||
|
|
||||||
|
## Threat model (read before deploying)
|
||||||
|
|
||||||
|
ttyd-over-claude exposes **shell-equivalent power** on junkpile. The defense is the
|
||||||
|
CF Access whitelist. Treat the whitelist as the security boundary:
|
||||||
|
|
||||||
|
- **Never** open the Access policy to "any Google account" or "any domain".
|
||||||
|
- **Always** keep `cloudflared-chat-saiden` and `ttyd-chat` localhost-bound.
|
||||||
|
- If the whitelist is ever modified, `auth_verify` the change with the Pilot first.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [x] ttyd installed on junkpile (1.7.7_11)
|
||||||
|
- [x] ttyd wrapper installed → `~/.local/bin/ttyd-wrapper.sh`
|
||||||
|
- [x] systemd units installed (`ttyd-chat`, `cloudflared-chat-saiden`)
|
||||||
|
- [ ] Tunnel created in CF dashboard → token in `/etc/cloudflared/chat-saiden.env`
|
||||||
|
- [ ] Public hostname `chat.saiden.dev → http://localhost:7681`
|
||||||
|
- [ ] CF Access app + Google IdP + whitelist configured
|
||||||
|
- [ ] WebSocket support enabled on Access app
|
||||||
|
- [ ] `sudo systemctl enable --now ttyd-chat cloudflared-chat-saiden`
|
||||||
|
- [ ] First successful login as adam.ladachowski@gmail.com
|
||||||
|
- [ ] Whitelist denial verified from second Google account
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Cloudflare dashboard setup — chat.saiden.dev (token-based)
|
||||||
|
|
||||||
|
Two artefacts get created in the Zero Trust dashboard:
|
||||||
|
1. **A tunnel** (`chat-saiden`) with its public hostname.
|
||||||
|
2. **An Access application** with Google IdP + whitelist.
|
||||||
|
|
||||||
|
Both are token/UI-managed (no local config files for tunnel ingress) to match the
|
||||||
|
existing `cloudflared-mesh` and `cloudflared-tensors-art` pattern on junkpile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Create the tunnel (5 minutes)
|
||||||
|
|
||||||
|
### 1. Open the Networks → Tunnels page
|
||||||
|
|
||||||
|
Zero Trust → **Networks** → **Tunnels** → **Create a tunnel**.
|
||||||
|
|
||||||
|
### 2. Pick connector type
|
||||||
|
|
||||||
|
Choose **Cloudflared**. Click Next.
|
||||||
|
|
||||||
|
### 3. Name + save
|
||||||
|
|
||||||
|
Tunnel name: `chat-saiden`. Click **Save tunnel**.
|
||||||
|
|
||||||
|
### 4. Get the token (DO NOT close this page)
|
||||||
|
|
||||||
|
The wizard shows install instructions for several platforms. The token is the
|
||||||
|
long base64 string inside the displayed command, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
cloudflared.exe service install eyJhIjoiOTVhZDNiYWEyYTRlY2RhMWUzODM0MmRm... # ← THIS PART
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy just the token string** (everything after `service install` for the
|
||||||
|
Windows command, or after `--token` for the Linux command — same string either
|
||||||
|
way). Save it for the next step.
|
||||||
|
|
||||||
|
### 5. Place the token on junkpile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh junkpile
|
||||||
|
sudo mkdir -p /etc/cloudflared
|
||||||
|
sudo tee /etc/cloudflared/chat-saiden.env > /dev/null <<'EOF'
|
||||||
|
TUNNEL_TOKEN=PASTE_THE_LONG_TOKEN_HERE
|
||||||
|
EOF
|
||||||
|
sudo chown root:chi /etc/cloudflared/chat-saiden.env
|
||||||
|
sudo chmod 0640 /etc/cloudflared/chat-saiden.env
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ Verify the file looks right:
|
||||||
|
```bash
|
||||||
|
sudo ls -la /etc/cloudflared/chat-saiden.env # should be -rw-r----- root:chi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Configure the public hostname
|
||||||
|
|
||||||
|
Back in the wizard → **Next** → **Public Hostname** tab → **Add a public hostname**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Subdomain | `chat` |
|
||||||
|
| Domain | `saiden.dev` |
|
||||||
|
| Path | (leave blank) |
|
||||||
|
| Type | `HTTP` |
|
||||||
|
| URL | `localhost:7681` |
|
||||||
|
|
||||||
|
**Additional application settings → TLS → No TLS Verify** can stay off (localhost).
|
||||||
|
**Additional application settings → Connection → Disable Chunked Encoding** must stay OFF.
|
||||||
|
|
||||||
|
Click **Save hostname**.
|
||||||
|
|
||||||
|
CF will auto-create the `chat.saiden.dev` proxy CNAME for you.
|
||||||
|
|
||||||
|
### 7. Verify the tunnel page
|
||||||
|
|
||||||
|
The tunnel page should now show:
|
||||||
|
- Connector: **Healthy** (or "No connectors yet" if you haven't started the service)
|
||||||
|
- Public hostname: `chat.saiden.dev → http://localhost:7681`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — CF Access application (5 minutes)
|
||||||
|
|
||||||
|
### 1. Add the Access application
|
||||||
|
|
||||||
|
Zero Trust → **Access** → **Applications** → **Add an application** → **Self-hosted**.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Application name | `chat-saiden` |
|
||||||
|
| Session duration | `24 hours` |
|
||||||
|
| Application domain | `chat.saiden.dev` |
|
||||||
|
| Path | (leave blank) |
|
||||||
|
| Identity providers | Google |
|
||||||
|
| Instant Auth | enabled |
|
||||||
|
|
||||||
|
Save → continue to policies.
|
||||||
|
|
||||||
|
### 2. Add the whitelist policy (THE SECURITY BOUNDARY)
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Policy name | `pilot-whitelist` |
|
||||||
|
| Action | **Allow** |
|
||||||
|
| Include rule | **Emails** = `adam.ladachowski@gmail.com` |
|
||||||
|
|
||||||
|
Save policy.
|
||||||
|
|
||||||
|
### 3. WebSocket support
|
||||||
|
|
||||||
|
Application **Settings → Advanced → WebSocket support** → enable.
|
||||||
|
|
||||||
|
This is mandatory; ttyd is WS-based.
|
||||||
|
|
||||||
|
### 4. Default deny (implicit, but verify)
|
||||||
|
|
||||||
|
With only one Allow rule, anyone not matching is denied by default — no extra
|
||||||
|
deny rule needed. To double-check, look at the policies list: it should show
|
||||||
|
**one** policy (`pilot-whitelist`, Allow) and nothing else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part C — Google IdP setup (one-time, skip if already done)
|
||||||
|
|
||||||
|
Zero Trust → **Settings → Authentication → Login methods → Add new → Google**.
|
||||||
|
|
||||||
|
OAuth client ID + secret come from
|
||||||
|
https://console.cloud.google.com → APIs & Services → Credentials.
|
||||||
|
|
||||||
|
Authorized redirect URI:
|
||||||
|
```
|
||||||
|
https://<your-team>.cloudflareaccess.com/cdn-cgi/access/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<your-team>` with the team domain shown at the top of the Zero Trust
|
||||||
|
dashboard. Save in CF wizard → click **Test**. Must succeed before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational hygiene
|
||||||
|
|
||||||
|
- The TUNNEL_TOKEN is a long-lived bearer credential. If junkpile is compromised
|
||||||
|
or you suspect the token leaked: dashboard → tunnel → **Refresh token**.
|
||||||
|
Update `/etc/cloudflared/chat-saiden.env` and `systemctl restart
|
||||||
|
cloudflared-chat-saiden`.
|
||||||
|
- Audit access logs weekly: Zero Trust → **Logs → Access**.
|
||||||
|
- To revoke a whitelist entry: edit `pilot-whitelist` policy, save. Existing
|
||||||
|
sessions are cut on next request (session lifetime ≤ 24h by config).
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# provision-tunnel.sh — run on FUJI (or wherever ~/.cloudflared/cert.pem lives).
|
||||||
|
# Creates the chat-saiden tunnel, places the DNS CNAME in the correct zone,
|
||||||
|
# and scp's the credentials JSON to junkpile.
|
||||||
|
#
|
||||||
|
# Idempotent: re-running just verifies + re-syncs credentials.
|
||||||
|
#
|
||||||
|
# Watch out: `cloudflared tunnel route dns` auto-zone-detection is unreliable
|
||||||
|
# across this multi-zone account (saiden.dev + tengu.to share a cert). We
|
||||||
|
# manage the CNAME via flarectl explicitly to dodge it.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TUNNEL_NAME="chat-saiden"
|
||||||
|
HOSTNAME="chat.saiden.dev"
|
||||||
|
ZONE="saiden.dev"
|
||||||
|
JUNKPILE_DEST="/etc/cloudflared/chat-saiden/chat-saiden.json"
|
||||||
|
|
||||||
|
if [[ ! -f "$HOME/.cloudflared/cert.pem" ]]; then
|
||||||
|
echo "ERROR: ~/.cloudflared/cert.pem missing — run 'cloudflared tunnel login' first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 1. Create tunnel (idempotent) ---
|
||||||
|
echo "[1/3] Tunnel"
|
||||||
|
if cloudflared tunnel list 2>/dev/null | awk '{print $2}' | grep -qx "$TUNNEL_NAME"; then
|
||||||
|
UUID=$(cloudflared tunnel list 2>/dev/null | awk -v n="$TUNNEL_NAME" '$2==n {print $1}')
|
||||||
|
echo " already exists, UUID=$UUID"
|
||||||
|
else
|
||||||
|
cloudflared tunnel create "$TUNNEL_NAME"
|
||||||
|
UUID=$(cloudflared tunnel list 2>/dev/null | awk -v n="$TUNNEL_NAME" '$2==n {print $1}')
|
||||||
|
echo " created, UUID=$UUID"
|
||||||
|
fi
|
||||||
|
CRED_FILE="$HOME/.cloudflared/${UUID}.json"
|
||||||
|
if [[ ! -f "$CRED_FILE" ]]; then
|
||||||
|
echo "ERROR: credentials missing at $CRED_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. DNS CNAME via flarectl ---
|
||||||
|
echo "[2/3] DNS"
|
||||||
|
EXPECTED_TARGET="${UUID}.cfargotunnel.com"
|
||||||
|
EXISTING=$(flarectl dns list --zone "$ZONE" 2>/dev/null | awk -v fqdn="${HOSTNAME}" '$0 ~ fqdn && $3=="CNAME"')
|
||||||
|
if [[ -n "$EXISTING" ]]; then
|
||||||
|
EX_CONTENT=$(echo "$EXISTING" | awk -F'|' '{gsub(/^ +| +$/,"",$5); print $5}')
|
||||||
|
if [[ "$EX_CONTENT" == "$EXPECTED_TARGET" ]]; then
|
||||||
|
echo " CNAME already correct: $HOSTNAME → $EXPECTED_TARGET"
|
||||||
|
else
|
||||||
|
echo " ERROR: CNAME exists for $HOSTNAME but points elsewhere: $EX_CONTENT"
|
||||||
|
echo " expected: $EXPECTED_TARGET — fix manually"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
flarectl dns create --zone "$ZONE" --type CNAME --name chat --content "$EXPECTED_TARGET" --proxy
|
||||||
|
echo " created: $HOSTNAME → $EXPECTED_TARGET (proxied)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Copy credentials to junkpile ---
|
||||||
|
echo "[3/3] Credentials → junkpile"
|
||||||
|
scp -q "$CRED_FILE" junkpile:/tmp/chat-saiden.json
|
||||||
|
ssh junkpile "sudo mkdir -p /etc/cloudflared/chat-saiden && sudo mv /tmp/chat-saiden.json $JUNKPILE_DEST && sudo chown root:chi $JUNKPILE_DEST && sudo chmod 0640 $JUNKPILE_DEST"
|
||||||
|
echo " → junkpile:$JUNKPILE_DEST (0640 root:chi)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Tunnel UUID: $UUID"
|
||||||
|
echo "Hostname: $HOSTNAME"
|
||||||
|
echo "CNAME target: $EXPECTED_TARGET"
|
||||||
|
echo
|
||||||
|
echo "Next: ssh junkpile 'bash ~/chat-saiden/install.sh'"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Deploy walkthrough — chat.saiden.dev (token-based)
|
||||||
|
|
||||||
|
End-to-end, in order. Each step has a verification.
|
||||||
|
|
||||||
|
## 1 — Sync repo to junkpile
|
||||||
|
|
||||||
|
From fuji:
|
||||||
|
```bash
|
||||||
|
rsync -avz --exclude='.git' ~/Projects/chat-saiden/ junkpile:~/chat-saiden/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2 — Install on junkpile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'bash ~/chat-saiden/install.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- Installs `ttyd` via brew (idempotent, robust to caveats exit codes)
|
||||||
|
- Drops the wrapper to `~/.local/bin/ttyd-wrapper.sh`
|
||||||
|
- Installs both systemd units to `/etc/systemd/system/`
|
||||||
|
- Stages `/etc/cloudflared/chat-saiden.env` with a PLACEHOLDER token (mode 0640 root:chi)
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'ttyd --version; ls -l ~/.local/bin/ttyd-wrapper.sh /etc/systemd/system/ttyd-chat.service /etc/systemd/system/cloudflared-chat-saiden.service /etc/cloudflared/chat-saiden.env'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3 — Create tunnel + Access app in CF Zero Trust dashboard
|
||||||
|
|
||||||
|
Browser-only. Follow **`cloudflare/dashboard-setup.md`** end-to-end:
|
||||||
|
- Part A: create `chat-saiden` tunnel, copy token, add public hostname `chat.saiden.dev → http://localhost:7681`
|
||||||
|
- Part B: create Access application `chat-saiden`, bind to Google IdP, add whitelist policy with `adam.ladachowski@gmail.com`
|
||||||
|
- Part B step 3: **enable WebSocket support** (mandatory)
|
||||||
|
- Part C: skip if Google IdP already configured
|
||||||
|
|
||||||
|
## 4 — Paste token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'sudo $EDITOR /etc/cloudflared/chat-saiden.env'
|
||||||
|
# Replace PLACEHOLDER_REPLACE_ME with the long token from the wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'sudo grep -c "PLACEHOLDER" /etc/cloudflared/chat-saiden.env'
|
||||||
|
# Should print 0 (no placeholder left)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5 — Start ttyd locally and smoke-test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'sudo systemctl enable --now ttyd-chat && systemctl status ttyd-chat --no-pager | head -10 && curl -sI http://localhost:7681 | head -3'
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for: `Active: active (running)` and `HTTP/1.1 200 OK`.
|
||||||
|
|
||||||
|
## 6 — Start tunnel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'sudo systemctl enable --now cloudflared-chat-saiden && journalctl -u cloudflared-chat-saiden -n 20 --no-pager'
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for "Registered tunnel connection" lines (typically 4 connections to different CF colos).
|
||||||
|
|
||||||
|
## 7 — End-to-end live test
|
||||||
|
|
||||||
|
⚠ **Pilot verification gate.** From your laptop, fresh incognito:
|
||||||
|
1. Visit `https://chat.saiden.dev`
|
||||||
|
2. CF Access wall → Google → `adam.ladachowski@gmail.com`
|
||||||
|
3. Land on BT banner + `claude` prompt
|
||||||
|
|
||||||
|
⚠ **Whitelist verification.** Second incognito, non-whitelisted Google account:
|
||||||
|
- Must be **denied** at Access wall.
|
||||||
|
- If you land on the terminal, the policy is wrong — **kill the units immediately**:
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'sudo systemctl stop cloudflared-chat-saiden ttyd-chat'
|
||||||
|
```
|
||||||
|
Then recheck the policy in the dashboard.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh junkpile 'sudo systemctl disable --now cloudflared-chat-saiden ttyd-chat'
|
||||||
|
```
|
||||||
|
Tunnel + DNS stay (managed in dashboard) — to fully remove, delete the tunnel
|
||||||
|
in the CF Zero Trust dashboard (Networks → Tunnels → chat-saiden → Delete).
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Per-tab sessions.** Each browser tab spawns a fresh `claude` process — no
|
||||||
|
shared in-process state. EEMS-stored memories are visible to all sessions.
|
||||||
|
- **5-min idle disconnect default.** Closing the tab kills `claude`. Add
|
||||||
|
`-t reconnect=10` to the ttyd unit if you want browser-side reconnect attempts
|
||||||
|
(note: a fresh `claude` process spawns — conversation context is lost).
|
||||||
|
- **Token rotation.** Dashboard → tunnel → Refresh token. Edit env file, restart unit.
|
||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — junkpile-side installer for chat-saiden.
|
||||||
|
# Idempotent. Robust to brew warnings.
|
||||||
|
#
|
||||||
|
# Prereq: tunnel credentials already copied to /etc/cloudflared/chat-saiden/chat-saiden.json
|
||||||
|
# (one-shot scp from fuji: see cloudflare/provision-tunnel.sh)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
echo "Installing chat-saiden from: $REPO_DIR"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- 1. ttyd binary ---
|
||||||
|
if ! command -v ttyd >/dev/null 2>&1; then
|
||||||
|
echo "[1/4] Installing ttyd via brew"
|
||||||
|
if brew install ttyd; then
|
||||||
|
echo " ttyd install succeeded"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
if command -v ttyd >/dev/null 2>&1; then
|
||||||
|
echo " brew exited $rc but ttyd is present, continuing"
|
||||||
|
else
|
||||||
|
echo "ERROR: ttyd not installed (brew rc=$rc)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[1/4] ttyd already present: $(ttyd --version 2>&1 | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. wrapper script ---
|
||||||
|
echo "[2/4] Installing wrapper"
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
install -m 0755 "$REPO_DIR/junkpile/ttyd-wrapper.sh" "$HOME/.local/bin/ttyd-wrapper.sh"
|
||||||
|
echo " → $HOME/.local/bin/ttyd-wrapper.sh"
|
||||||
|
|
||||||
|
# --- 3. tunnel config ---
|
||||||
|
echo "[3/4] Installing tunnel config"
|
||||||
|
sudo mkdir -p /etc/cloudflared/chat-saiden
|
||||||
|
sudo install -m 0644 -o root -g chi "$REPO_DIR/junkpile/chat-saiden.yml" /etc/cloudflared/chat-saiden/chat-saiden.yml
|
||||||
|
echo " → /etc/cloudflared/chat-saiden/chat-saiden.yml"
|
||||||
|
|
||||||
|
if [[ ! -f /etc/cloudflared/chat-saiden/chat-saiden.json ]]; then
|
||||||
|
echo " WARNING: credentials JSON missing at /etc/cloudflared/chat-saiden/chat-saiden.json"
|
||||||
|
echo " Provision it via cloudflare/provision-tunnel.sh from fuji."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. systemd units ---
|
||||||
|
echo "[4/4] Installing systemd units"
|
||||||
|
sudo install -m 0644 "$REPO_DIR/junkpile/ttyd-chat.service" /etc/systemd/system/ttyd-chat.service
|
||||||
|
sudo install -m 0644 "$REPO_DIR/junkpile/cloudflared-chat-saiden.service" /etc/systemd/system/cloudflared-chat-saiden.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
echo " → /etc/systemd/system/ttyd-chat.service"
|
||||||
|
echo " → /etc/systemd/system/cloudflared-chat-saiden.service"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " INSTALL DONE — verify:"
|
||||||
|
echo "============================================================"
|
||||||
|
systemctl status ttyd-chat.service --no-pager 2>&1 | head -3 || true
|
||||||
|
systemctl status cloudflared-chat-saiden.service --no-pager 2>&1 | head -3 || true
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " NEXT STEPS"
|
||||||
|
echo "============================================================"
|
||||||
|
echo " A. Verify credentials present:"
|
||||||
|
echo " sudo ls -la /etc/cloudflared/chat-saiden/"
|
||||||
|
echo " # should show chat-saiden.json (0640 root:chi) + chat-saiden.yml"
|
||||||
|
echo
|
||||||
|
echo " B. Configure CF Access (browser, one-time):"
|
||||||
|
echo " see $REPO_DIR/cloudflare/dashboard-setup.md (Part B + WebSocket only;"
|
||||||
|
echo " tunnel half is already done via cloudflare/provision-tunnel.sh)"
|
||||||
|
echo
|
||||||
|
echo " C. Start the services:"
|
||||||
|
echo " sudo systemctl enable --now ttyd-chat"
|
||||||
|
echo " sudo systemctl enable --now cloudflared-chat-saiden"
|
||||||
|
echo
|
||||||
|
echo " D. Smoke-test:"
|
||||||
|
echo " curl -sI https://chat.saiden.dev # expect CF Access redirect"
|
||||||
|
echo "============================================================"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# cloudflared config for the chat-saiden tunnel.
|
||||||
|
# Installed to: /etc/cloudflared/chat-saiden/chat-saiden.yml
|
||||||
|
#
|
||||||
|
# Tunnel created from fuji via:
|
||||||
|
# cloudflared tunnel create chat-saiden
|
||||||
|
# cloudflared tunnel route dns chat-saiden chat.saiden.dev
|
||||||
|
# (CNAME corrected manually — auto-zone-detection landed it in the wrong
|
||||||
|
# zone; flarectl was used to recreate in saiden.dev)
|
||||||
|
#
|
||||||
|
# Credentials JSON was scp'd from fuji ~/.cloudflared/<UUID>.json
|
||||||
|
|
||||||
|
tunnel: f03da7b7-7219-4039-95ca-a3293152781b
|
||||||
|
credentials-file: /etc/cloudflared/chat-saiden/chat-saiden.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- hostname: chat.saiden.dev
|
||||||
|
service: http://localhost:7681
|
||||||
|
originRequest:
|
||||||
|
# ttyd uses websockets — long-lived connections
|
||||||
|
connectTimeout: 30s
|
||||||
|
tcpKeepAlive: 30s
|
||||||
|
keepAliveTimeout: 90s
|
||||||
|
- service: http_status:404
|
||||||
|
|
||||||
|
metrics: localhost:42041
|
||||||
|
no-autoupdate: true
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Cloudflare Tunnel — chat.saiden.dev
|
||||||
|
After=network-online.target ttyd-chat.service
|
||||||
|
Wants=network-online.target
|
||||||
|
# Don't start tunnel if ttyd isn't there — origin would 502
|
||||||
|
Requires=ttyd-chat.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=chi
|
||||||
|
Group=chi
|
||||||
|
WorkingDirectory=/home/chi
|
||||||
|
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel --config /etc/cloudflared/chat-saiden/chat-saiden.yml run
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadOnlyPaths=/etc/cloudflared/chat-saiden
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ttyd — chat.saiden.dev web terminal (BT-7274 bridge)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=chi
|
||||||
|
Group=chi
|
||||||
|
WorkingDirectory=/home/chi
|
||||||
|
Environment=HOME=/home/chi
|
||||||
|
Environment=TERM=xterm-256color
|
||||||
|
Environment=LANG=en_US.UTF-8
|
||||||
|
|
||||||
|
# ttyd flags:
|
||||||
|
# -p 7681 : listen on this port
|
||||||
|
# -i lo : LOCALHOST ONLY — public path is via cloudflared
|
||||||
|
# -W : enable writable terminal (input from browser)
|
||||||
|
# -t titleFixed=... : browser tab title
|
||||||
|
# -t fontFamily=... : font
|
||||||
|
# -t fontSize=14
|
||||||
|
# -t cursorBlink=true
|
||||||
|
# -t theme={...} : Saiden dark palette
|
||||||
|
# -T xterm-256color
|
||||||
|
# -O : check origin (CSRF defense)
|
||||||
|
# -c chi:DUMMY : ttyd basic auth — ignored, CF Access is the real gate,
|
||||||
|
# but enabling -c blocks accidental direct access
|
||||||
|
ExecStart=/home/linuxbrew/.linuxbrew/bin/ttyd \
|
||||||
|
-p 7681 \
|
||||||
|
-i lo \
|
||||||
|
-W \
|
||||||
|
-O \
|
||||||
|
-T xterm-256color \
|
||||||
|
-t titleFixed='BT-7274 — chat.saiden.dev' \
|
||||||
|
-t fontFamily='JetBrains Mono, Menlo, monospace' \
|
||||||
|
-t fontSize=14 \
|
||||||
|
-t cursorBlink=true \
|
||||||
|
-t cursorStyle=bar \
|
||||||
|
-t 'theme={"background":"#0a0d10","foreground":"#c8d3d8","cursor":"#7fb069","cursorAccent":"#0a0d10","selectionBackground":"#1f2a30","black":"#0a0d10","red":"#c94f4f","green":"#7fb069","yellow":"#d4a85a","blue":"#5c8fb8","magenta":"#a070b8","cyan":"#5cb8a8","white":"#c8d3d8","brightBlack":"#3a4248","brightRed":"#e06a6a","brightGreen":"#9ec77f","brightYellow":"#e8c275","brightBlue":"#7eb0d4","brightMagenta":"#bb8fce","brightCyan":"#7fd4c4","brightWhite":"#e8edf0"}' \
|
||||||
|
/home/chi/.local/bin/ttyd-wrapper.sh
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Hardening — ttyd doesn't need much
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=/home/chi/.marauder /home/chi/.claude /tmp
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ttyd-wrapper.sh — entrypoint launched by ttyd for each browser session.
|
||||||
|
# Ensures bt7274 cart is active, then handoff to `claude`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Make sure marauder + claude on PATH for non-login shells
|
||||||
|
export PATH="/home/chi/.local/bin:/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
|
||||||
|
# Set the persona for this session (idempotent if already active globally)
|
||||||
|
marauder cart use bt7274 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Optional banner — confirms this is the right channel
|
||||||
|
cat <<'BANNER'
|
||||||
|
╔════════════════════════════════════════════════════════════════╗
|
||||||
|
║ SAIDEN TACTICAL SYSTEMS — MARAUDER REMOTE BRIDGE ║
|
||||||
|
║ Operator: BT-7274 • Channel: chat.saiden.dev ║
|
||||||
|
║ Host: junkpile • Authenticated via CF Access ║
|
||||||
|
║ ║
|
||||||
|
║ Sign out: https://chat.saiden.dev/cdn-cgi/access/logout ║
|
||||||
|
╚════════════════════════════════════════════════════════════════╝
|
||||||
|
BANNER
|
||||||
|
|
||||||
|
# Hand off to the Pilot's Titan
|
||||||
|
exec claude
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[project]
|
||||||
|
name = "chat-saiden"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "TUI-styled web chat with BT-7274 — chat.saiden.dev"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"websockets>=13",
|
||||||
|
"anthropic>=0.40",
|
||||||
|
"authlib>=1.3",
|
||||||
|
"itsdangerous>=2.2", # session cookie signing
|
||||||
|
"httpx>=0.27", # for authlib OAuth
|
||||||
|
"jinja2>=3.1", # template rendering
|
||||||
|
"python-multipart>=0.0.12", # form parsing
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
Reference in New Issue
Block a user