EXEPERT Brain Chat
The left-panel Chat mode is now a real AI chatbot instead of a canned response generator. The browser streams responses through Supabase Edge Functions, the Edge Function calls 9Router with server-only credentials, and each assistant turn is persisted as Phoenix/OpenInference-style observability data.
The chat tile separates simulation context from AI routing:
- Brain Mode is the active EXEPERT brain/simulation mode.
- Model is the active AI provider/model selected from the 9Router-backed picker.
- Each assistant message keeps the provider/model that produced it, so old transcript entries remain understandable after the user changes models.
User flow
- The user opens the Chat tab in the left panel.
src/brain-ui.tssigns the visitor in anonymously when needed and provisions a playground project with the same pattern used by the Prompt Playground.fetchChatModels()loads the available 9Router models throughGET /functions/v1/run-chat?models=1, then stores the selected model inlocalStorageunderexepert.brainChat.model.v1.- The browser sends the current transcript, the selected
model_id, the new user message, and live brain telemetry to therun-chatEdge Function. run-chatvalidates the Supabase user token, checks project ownership, calls 9Router, and streams tokens back as server-sent events.- The browser classifies the user's text with the local affect engine, immediately pulses the brain canvas, and renders an affect chip on the user message.
- When the stream finishes,
run-chatwrites asessionsrow, atracesrow, and aspansrow for the chat completion. - The assistant message is classified, pulses the canvas again, and shows the provider/model footer, affect chip, and feedback controls.
- Affect records call
chat-affect, which writes syntheticaffective_stateannotations on the same assistant span. A thumbs vote callschat-feedback, which writes a Phoenix-styleuser_feedbackannotation on the assistant span.
Frontend changes
src/brain-ui.ts
brain-ui.ts owns the visible chat panel and now coordinates the real runtime:
- keeps a capped transcript in
localStorageunderexepert.brainChat.history.v1; - keeps a stable session key in
exepert.brainChat.session.v1; - keeps the selected 9Router model in
exepert.brainChat.model.v1; - signs in anonymously if the user is not already authenticated;
- calls
ensurePlaygroundProject()and sets the active project; - loads the 9Router model list through the Edge Function and renders the provider icon, readable label, and raw model id in the chat header;
- renders the app-owned searchable model menu with provider groups, variant badges, active/selected rows, click-away dismissal, and keyboard selection;
- streams assistant tokens into the existing chat message column;
- injects language-region bursts while the answer is streaming;
- adds a New chat action in the header;
- stores
trace_id,span_id,turn_id, provider, model, and request metadata on assistant messages after the Edge Function sends the finaldoneevent; - renders a provider/model footer under assistant messages;
- renders feedback buttons for persisted assistant messages;
- renders compact, responsive failure cards for chat route errors and keeps raw router payloads out of the transcript UI;
- classifies each user, assistant, and chat-error message through
src/affect/classifier.ts, renders affect chips, updates the Affective Field readout, and triggers emotion-colored brain pulses; - writes local and optionally LLM-refined affect annotations through
chat-affectafter the chat span is available.
The old fake response path (buildChatResponse) is no longer used. If the
request fails, the UI keeps the user's prompt in the transcript and replaces the
assistant placeholder with a .chat-error-card.
src/affect/*
The affect layer is intentionally synthetic research telemetry. It does not claim to detect a user's true emotion or diagnose mental state.
types.tsdefinesAffectAnalysis,AffectEvent,EmotionKey, andAffectStimulus.palette.tsassigns the dramatic emotion colors used by the canvas and UI.classifier.tsis the local deterministic classifier. It scores anger, fear, sadness, joy, disgust, surprise, curiosity, calm, distress, and neutral. Profanity is context-aware: "fuck yeah" routes toward high-arousal joy, while directed hostile profanity routes toward anger/distress and higher toxicity.mapping.tsmaps affect to the existing brain regions: visual, auditory, language, attention, or global. It also hashes text before persistence.client.tsposts affect events tochat-affectwith the Supabase access token.
The selected affect state is also included in the chat telemetry context as
affect_dominant, affect_intensity, affect_valence, and
affect_arousal, so the assistant can describe the visible affect layer when
asked.
src/chat/client.ts
The chat client is the browser boundary for Edge Function calls. It exports:
| Export | Purpose |
|---|---|
buildRunChatRequest() | Creates the request payload, includes the selected model_id, and caps history to the last 12 non-empty messages. |
fetchChatModels() | Loads sanitized model metadata through run-chat?models=1 and falls back to cx/gpt-5.5 if discovery fails. |
normalizeChatErrorPayload() | Converts structured Edge Function errors and legacy plain message errors into compact UI-safe error objects. |
parseSseLines() | Parses event: / data: SSE records while preserving partial chunks. |
streamChatCompletion() | Calls run-chat with the Supabase access token and routes token, done, and structured error events to callbacks. done includes provider, model, and request_id. |
submitChatFeedback() | Calls chat-feedback with the Supabase access token. |
The browser never receives the 9Router key. It only sends the user's Supabase JWT to the Edge Functions.
Markup and styles
index.html adds the .brain-model-picker wrapper, #brainModelTrigger,
#brainModelMenu, #brainModelSearch, #brainModelList, #newBrainChat, and
the separate Model context pill. It also adds #affectField in the chat tile
and #affectStageOverlay above the brain canvas. The previous native
#brainModelSelect was
removed so the browser no longer renders an unstyled white operating-system
dropdown inside the dark app shell.
src/brain-ui.ts keeps the existing model state (chatModels,
selectedChatModel, CHAT_MODEL_KEY) but renders a custom listbox instead of
<option> elements. The menu:
- groups rows by
providerLabel()in the order Codex, Claude, Generic; - filters by provider, label, model id, and
owned_by; - shows model-family badges for ids containing
review,mini,xhigh,high,low,none, orspark; - supports Escape, ArrowUp, ArrowDown, Enter, click, and outside-click close;
- closes and disables while
chatStreamingis active; - marks models that fail with
model_unavailableas disabled for the current browser session.
css/app.css keeps the trigger compact in the narrow left panel, renders the
popover as a viewport-clamped dark app-owned surface, keeps the search row
sticky, moves badges under the model id before hiding them at very narrow widths,
switches the context row to four stable pills, and styles the assistant
provider/model footer plus responsive .chat-error-card failures. The affect
CSS renders a compact dark Affective Field, message chips, and a short-lived
stage overlay using the dominant emotion color as an accent.
Responsive failure and model-availability UX
The chat UI treats provider failures as recoverable routing state instead of dumping raw Supabase or 9Router error text into a chat bubble.
Code changes:
supabase/functions/run-chat/error-classification.tscentralizes error classification and redaction for router payloads.supabase/functions/run-chat/index.tspreserves router HTTP status/detail, emits structuredevent: errorpayloads, and keeps the legacyerrorstring for older clients.src/chat/client.tsnormalizes both structured errors and legacy string errors intoChatErrorInfo.src/brain-ui.tsrenders.chat-error-card, tracksmodel_unavailablefailures in memory, disables those model rows for the current browser session, and offersRetry,Use default model, andChoose modelactions when appropriate.css/app.cssadds wrapping rules for chat content, viewport-clamped model menu sizing, sticky model search, muted unavailable rows, and mobile rules for narrow chat panels.src/__tests__/chat-client.test.tsandsrc/__tests__/run-chat-errors.test.tscover legacy SSE error parsing, structured SSE errors, and router error classification.
Behavior:
- Deprecated or unavailable models become Model unavailable cards and are marked Unavailable in the picker.
- Temporary router failures, timeouts, and 5xx responses show a retryable router message.
- Rate limits show a retryable rate-limit message.
- Auth and configuration failures show a non-retryable service message.
Retryresends the same prompt only when the error is retryable.Use default modelswitches back tocx/gpt-5.5and restores the prompt in the input without auto-sending.Choose modelopens the same searchable model picker so the user can switch routes manually.
The browser UI intentionally does not preflight every listed model. A model is marked unavailable only after a real chat completion request fails for that model.
Local provider icons live under public/icons/providers/:
claude.svgfor Claude/Anthropic models.openai.svgfor Codex/OpenAI/cx/GPT models.generic.svgfor unknown model families.
The UI never fetches provider icons at runtime from external sites.
Edge Functions
supabase/functions/run-chat/index.ts
run-chat is an authenticated Edge Function. It requires a Bearer Supabase
access token from the browser and server-side 9Router secrets from the Edge
Function environment.
Responsibilities:
- serve authenticated
GET ?models=1requests by calling 9Router/models, normalizing provider labels/icons, and returning sanitized model metadata; - cache the model list in memory for a short TTL, with a short fallback TTL when listing fails;
- reject unsupported methods and invalid tokens;
- enforce a per-user in-memory rate limit of 20 chat requests per minute;
- check that
projects.owner_idmatches the authenticated user; - validate the requested
model_idagainst the cached 9Router model list or the fallback default before calling the provider; - build a system message from live brain telemetry;
- call the OpenAI-compatible 9Router
/chat/completionsendpoint withstream: true; - forward provider chunks as
event: tokenSSE messages; - persist the completed turn as:
sessions.session_keyfor the browser chat session;- a
tracesrow namedchat.turn; - a
spansrow named9router.chat.completionwithspan_kind = 'LLM';
- send
event: donewithtrace_id,span_id,otel_trace_id,otel_span_id, model, provider,request_id, latency, output, and usage when available; - classify provider failures as
model_unavailable,router_unavailable,rate_limited,chat_service_unavailable, orunknownand emit a backward-compatibleevent: errorpayload withcode,title,message,model_id,request_id,retryable, and legacyerror; - persist an
ERRORspan on provider failure when possible. When error persistence succeeds, theevent: errorpayload also includestrace_id,span_id,otel_trace_id, andotel_span_idso the browser can attach anassistant_erroraffect annotation.
Persisted spans set llm_model to the selected model, set
attributes.llm.provider to the resolved provider, and set
attributes.llm.router to 9router.
Required secrets:
ROUTER_BASE_URL=https://your-router.example.com/v1
ROUTER_API_KEY=...
ROUTER_DEFAULT_MODEL=cx/gpt-5.5
ROUTER_AFFECT_MODEL=cx/gpt-5.5 # optional; defaults to ROUTER_DEFAULT_MODEL
For local development, ROUTER_BASE_URL can point at the VPS HTTP endpoint.
For production, use an HTTPS domain in front of the router.
supabase/functions/chat-feedback/index.ts
chat-feedback writes the user's thumbs vote as a Phoenix-style span
annotation. It:
- validates the Supabase user token;
- rate limits to 60 feedback writes per user per minute;
- checks project ownership;
- verifies that the target span belongs to the supplied project and trace;
- upserts an
annotation_configsrow foruser_feedback; - upserts a
span_annotationsrow with:name = 'user_feedback';annotator_kind = 'HUMAN';identifier = chat-turn:<turn_id>:user:<user_id>;label = positive | negative;score = 1 | 0;- metadata for
source,user_id,session_key,turn_id, andtrace_id.
The identifier makes repeated feedback writes update the same vote for one chat turn while still allowing multiple annotation values on a span.
supabase/functions/chat-affect/index.ts
chat-affect persists the synthetic affect layer as span annotations. It:
- validates the Supabase user token;
- rate limits to 90 affect writes per user per minute;
- checks project ownership;
- verifies that the target span belongs to the supplied project and trace;
- accepts the local
AffectAnalysisfrom the browser; - optionally refines the analysis through 9Router using
ROUTER_AFFECT_MODELorROUTER_DEFAULT_MODEL; - upserts an
annotation_configsrow foraffective_state; - upserts a
span_annotationsrow with:name = 'affective_state';annotator_kind = 'CODE'for local analysis orLLMfor refinement;identifier = chat-turn:<turn_id>:phase:<phase>:source:<source>;label = dominant emotion;score = intensity;- metadata for source, user, session, phase, text hash, selected chat model, affect model, palette, region mix, classifier version, and score vectors.
The endpoint never stores raw message text in the annotation metadata. The raw text is already represented in the chat span's OpenInference attributes; affect metadata stores only the provided text hash.
supabase/functions/run-eval/index.ts
run-eval was updated to include an identifier in evaluator annotation
upserts. Its conflict target now matches the new database uniqueness rule:
span_id,name,annotator_kind,identifier
Redeploy run-eval with the chat functions after applying the migration.
Database migration
Migration 20260615163726_phoenix_chat_annotations.sql makes annotation writes
match the Phoenix v9 pattern:
- adds
span_annotations.identifier text not null default ''; - replaces the old uniqueness rule on
(span_id, name, annotator_kind)with(span_id, name, annotator_kind, identifier); - adds a partial unique index on
sessions(project_id, session_key)wheresession_key is not null.
The handwritten Supabase types in src/data/database.types.ts now include the
identifier field. submitHumanAnnotation() in src/data/dal.ts accepts
optional identifier and metadata values and uses the widened conflict
target.
Security model
- 9Router credentials are server-only and must never be prefixed with
VITE_. - The browser calls only Supabase Edge Functions using the current Supabase session token.
- Diagnostics redact authorization headers, API keys, service role keys,
passwords, bearer tokens, and
sk-...style keys before writing to the in-app panel or console. - Edge Functions use the service role key only after validating the user and project ownership.
- Production should use HTTPS for the 9Router base URL. The raw VPS HTTP URL is acceptable only for development.
- Rotate any 9Router key that was pasted into local chat or terminal logs before production use.
Deployment checklist
Apply the migration and deploy all affected functions:
pnpm exec supabase db push --linked
pnpm exec supabase secrets set ROUTER_BASE_URL=https://your-router.example.com/v1
pnpm exec supabase secrets set ROUTER_API_KEY=...
pnpm exec supabase secrets set ROUTER_DEFAULT_MODEL=cx/gpt-5.5
pnpm exec supabase secrets set ROUTER_AFFECT_MODEL=cx/gpt-5.5
pnpm exec supabase functions deploy run-chat
pnpm exec supabase functions deploy chat-affect
pnpm exec supabase functions deploy chat-feedback
pnpm exec supabase functions deploy run-eval
Cloud secret and deploy commands require either supabase login or
SUPABASE_ACCESS_TOKEN. Security advisors also require SUPABASE_DB_PASSWORD.
Verification
The implementation was checked with:
pnpm typecheck
pnpm exec vitest run
pnpm build
pnpm --dir docs-site build
git diff --check
Local Supabase reset/start could not be completed during the implementation
because an older migration references public.template_type before the later
prompt-versioning migration creates it. That ordering issue predates the chat
work and is separate from this feature.