feat(transport): swap Anthropic → opencode; add persona switcher

Part 1 — Transport swap:
- Replace anthropic.AsyncAnthropic streaming with httpx SSE client
  calling opencode's OpenAI-compat /v1/chat/completions on sin:4096
- Auth: basic auth opencode:$OPENCODE_PASSWORD
- Env: OPENCODE_URL (default http://sin:4096), OPENCODE_PASSWORD
- Sidecar binding (sin:4098) consulted per message to resolve active
  persona; voice read from binding → cart → env default
- Helper _session_id_for_user: deterministic sha256 slug per email
  so sidecar binding survives WebSocket reconnects
- anthropic dep retained in pyproject.toml (not removed — P4 may use it)

Part 2 — Persona switcher:
- PERSONAS dict: bt7274, friday, samantha (slug → voice/backend/prompt)
- POST /api/persona  — bind persona via sidecar, maps slug → full config
- GET  /api/persona/current — return current binding
- GET  /api/personas — list available personas
- chat.html: persona <select> in topnav with server-rendered active state
- chat.js: onChange → fetch /api/persona, update __personaName +
  status badge + system message in conversation feed

TODO: add CSS polish for .topnav__persona-wrap (inherits base styles for now)
This commit is contained in:
marauder-actual
2026-05-29 14:18:47 +02:00
parent 96ba8f4b6e
commit b0893a3699
3 changed files with 378 additions and 33 deletions
+18
View File
@@ -23,10 +23,28 @@
<script>
window.__pilotName = {{ pilot_name | tojson }};
window.__personaName = {{ persona_name | tojson }};
window.__personas = {{ personas | tojson }};
window.__boundSlug = {{ bound_slug | 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>
<!-- Persona switcher -->
<span class="topnav__persona-wrap">
<label class="topnav__persona-label" for="persona-select">persona</label>
<select class="topnav__persona-select" id="persona-select" title="Switch persona">
<option value="">— default —</option>
{% for p in personas %}
<option value="{{ p.slug }}"{% if p.slug == bound_slug %} selected{% endif %}>{{ p.display }}</option>
{% endfor %}
</select>
<span class="topnav__persona-status" id="persona-status">
{% if bound_display %}{{ bound_display }}{% endif %}
</span>
</span>
<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">