Skip to main content
Version: Next

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

  1. The user opens the Chat tab in the left panel.
  2. src/brain-ui.ts signs the visitor in anonymously when needed and provisions a playground project with the same pattern used by the Prompt Playground.
  3. fetchChatModels() loads the available 9Router models through GET /functions/v1/run-chat?models=1, then stores the selected model in localStorage under exepert.brainChat.model.v1.
  4. The browser sends the current transcript, the selected model_id, the new user message, and live brain telemetry to the run-chat Edge Function.
  5. run-chat validates the Supabase user token, checks project ownership, calls 9Router, and streams tokens back as server-sent events.
  6. 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.
  7. When the stream finishes, run-chat writes a sessions row, a traces row, and a spans row for the chat completion.
  8. The assistant message is classified, pulses the canvas again, and shows the provider/model footer, affect chip, and feedback controls.
  9. Affect records call chat-affect, which writes synthetic affective_state annotations on the same assistant span. A thumbs vote calls chat-feedback, which writes a Phoenix-style user_feedback annotation 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 localStorage under exepert.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 final done event;
  • 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-affect after 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.ts defines AffectAnalysis, AffectEvent, EmotionKey, and AffectStimulus.
  • palette.ts assigns the dramatic emotion colors used by the canvas and UI.
  • classifier.ts is 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.ts maps affect to the existing brain regions: visual, auditory, language, attention, or global. It also hashes text before persistence.
  • client.ts posts affect events to chat-affect with 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:

ExportPurpose
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, or spark;
  • supports Escape, ArrowUp, ArrowDown, Enter, click, and outside-click close;
  • closes and disables while chatStreaming is active;
  • marks models that fail with model_unavailable as 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.ts centralizes error classification and redaction for router payloads.
  • supabase/functions/run-chat/index.ts preserves router HTTP status/detail, emits structured event: error payloads, and keeps the legacy error string for older clients.
  • src/chat/client.ts normalizes both structured errors and legacy string errors into ChatErrorInfo.
  • src/brain-ui.ts renders .chat-error-card, tracks model_unavailable failures in memory, disables those model rows for the current browser session, and offers Retry, Use default model, and Choose model actions when appropriate.
  • css/app.css adds 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.ts and src/__tests__/run-chat-errors.test.ts cover 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.
  • Retry resends the same prompt only when the error is retryable.
  • Use default model switches back to cx/gpt-5.5 and restores the prompt in the input without auto-sending.
  • Choose model opens 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.svg for Claude/Anthropic models.
  • openai.svg for Codex/OpenAI/cx/GPT models.
  • generic.svg for 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=1 requests 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_id matches the authenticated user;
  • validate the requested model_id against 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/completions endpoint with stream: true;
  • forward provider chunks as event: token SSE messages;
  • persist the completed turn as:
    • sessions.session_key for the browser chat session;
    • a traces row named chat.turn;
    • a spans row named 9router.chat.completion with span_kind = 'LLM';
  • send event: done with trace_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, or unknown and emit a backward-compatible event: error payload with code, title, message, model_id, request_id, retryable, and legacy error;
  • persist an ERROR span on provider failure when possible. When error persistence succeeds, the event: error payload also includes trace_id, span_id, otel_trace_id, and otel_span_id so the browser can attach an assistant_error affect 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_configs row for user_feedback;
  • upserts a span_annotations row 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, and trace_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 AffectAnalysis from the browser;
  • optionally refines the analysis through 9Router using ROUTER_AFFECT_MODEL or ROUTER_DEFAULT_MODEL;
  • upserts an annotation_configs row for affective_state;
  • upserts a span_annotations row with:
    • name = 'affective_state';
    • annotator_kind = 'CODE' for local analysis or LLM for 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) where session_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.