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
+47
View File
@@ -411,6 +411,53 @@ if ($recal) {
});
}
// ---------- persona switcher ----------
const $personaSelect = document.getElementById('persona-select');
const $personaStatus = document.getElementById('persona-status');
if ($personaSelect) {
// Set initial selection from server-rendered bound slug
if (window.__boundSlug) {
$personaSelect.value = window.__boundSlug;
}
$personaSelect.addEventListener('change', async () => {
const slug = $personaSelect.value;
if (!slug) return; // "— default —" selected
$personaSelect.disabled = true;
if ($personaStatus) $personaStatus.textContent = 'binding…';
try {
const resp = await fetch('/api/persona', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data = await resp.json();
// Update display name in UI
if ($personaStatus) $personaStatus.textContent = data.display || slug;
// Update the speaker label for subsequent BT messages
if (data.display) window.__personaName = data.display;
// Subtle system message in conversation so the switch is visible
const body = makeMsg('system');
body.textContent = `persona bound → ${data.display || slug}`;
} catch (err) {
console.warn('persona bind failed:', err.message);
if ($personaStatus) $personaStatus.textContent = 'bind failed';
// revert select to previous value
$personaSelect.value = window.__boundSlug || '';
} finally {
$personaSelect.disabled = false;
}
});
}
// ---------- init ----------
connect();