Skip to main content
Version: Next

EXEPERT Brain Chat

The left-panel Chat mode uses a validated research-response contract. The browser calls JWT-protected Supabase Edge Functions, the server calls 9Router with a dedicated credential, and each assistant turn is persisted as Phoenix/OpenInference-style observability data. New clients receive one bounded exepert-research-v1 object; the original SSE transport remains only for older clients during the rollout window.

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 verified 9Router catalog through the dedicated chat-models Edge Function. Refresh, stale-cache, and failure states remain distinct. The selected model is stored under exepert.brainChat.model.v1 only while it remains in the server catalog.
  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, selects at most two relevant public Journal entries, calls 9Router, and validates one structured response.
  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. run-chat writes a sessions row, a traces row, and a spans row. The span stores both the validated JSON and a deterministic text projection.
  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 model route in exepert.brainChat.model.v1;
  • signs in anonymously if the user is not already authenticated;
  • calls ensurePlaygroundProject() and sets the active project;
  • loads the verified model list through chat-models and exposes distinct loading, refresh, retry, stale-cache, and unavailable states;
  • renders the app-owned searchable model menu with public model-family groups, friendly names, variant badges, active/selected rows, click-away dismissal, and keyboard selection;
  • keeps route identifiers searchable while rendering them only through the default-collapsed, non-persistent Technical names control;
  • shows a live progress state while the structured answer is being built;
  • safely renders non-empty Reply, Key Points, Evidence, Limitations, and Next Steps sections with Journal links restricted to known local slugs;
  • adds a New chat action in the header;
  • stores trace_id, span_id, turn_id, requested route, resolved model, provider, structured-fallback state, and request metadata on assistant messages after the Edge Function returns;
  • renders a friendly route/resolved-model footer under assistant messages and puts exact identifiers in a collapsed technical disclosure;
  • 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 verified model and combo metadata through chat-models, supports explicit refresh, and throws a visible error if cold discovery fails.
normalizeChatErrorPayload()Converts structured Edge Function errors and legacy plain message errors into compact UI-safe error objects.
completeResearchChat()Calls run-chat with research-v1, validates exepert-research-v1, and returns requested/route/resolved model plus observability metadata.
parseSseLines() / streamChatCompletion()Preserve the temporary legacy SSE transport for older deployed clients.
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, #brainModelTechnicalToggle, #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 curated routes under EXEPERT Models and individual routes under Gemini, Claude, OpenAI, MiMo, or Other Models;
  • formats route identifiers into stable public names and moves variant tokens such as low, preview, agent, and thinking into badges;
  • filters by the public presentation fields and hidden route/source values, so technical searches still work while the disclosure is collapsed;
  • exposes exact route/source values only after the user activates the non-persistent Technical names toggle;
  • supports Escape, ArrowUp, ArrowDown, Enter, click, and outside-click close;
  • closes and disables while a chat request 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 and tool rows stable, wraps technical identifiers, switches the context row to four stable pills, and styles the assistant model footer, technical disclosure, and 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 service 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, then rejects public titles or messages that contain private infrastructure terminology.
  • 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 EXEPERT Chat, 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, public-copy sanitization, and internal error-code compatibility.

Behavior:

  • Deprecated or unavailable models become Model unavailable cards and are marked Unavailable in the picker.
  • Temporary upstream failures, timeouts, and 5xx responses show AI service temporarily unavailable.
  • Rate limits show a retryable Model busy message.
  • Auth and configuration failures show a non-retryable service message.
  • Retry resends the same prompt only when the error is retryable.
  • Use EXEPERT Chat switches back to exepert-chat 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.

Public and technical model identity

src/chat/models.ts keeps presentation separate from transport metadata. Each normalized row exposes displayName, family, badges, and technicalSource alongside its existing route id, provider, ownership, combo, and member fields. Public names are derived locally instead of trusting an upstream label. Technical values remain available for requests, hidden-id search, transcript diagnostics, and observability.

Assistant metadata follows the same rule. The visible footer reads in the form EXEPERT Chat · Gemini 3.1 Pro; requested, route, and resolved ids appear only inside a native collapsed <details> element. Old stored transcripts are normalized again during rendering, so legacy labels cannot reintroduce private infrastructure branding.

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/chat-models/index.ts

chat-models is the authenticated discovery boundary. It calls 9Router /models, combines live rows with validated member metadata for a combo that was already confirmed live, and returns the server default, combo order, freshness timestamp, and stale warning. A POST body with refresh: true bypasses the five-minute successful cache. Cold failures return an explicit error; the function never fabricates a one-model success.

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:

  • retain GET ?models=1 as a temporary authenticated delegate to the shared discovery adapter;
  • 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;
  • resolve the requested model through the verified requested/default/combo/first order;
  • build a system message from simulated brain telemetry and at most two relevant public Journal entries within a 6,000-character budget;
  • call the OpenAI-compatible 9Router /chat/completions endpoint with stream: false, a strict JSON schema, a 45-second timeout, and an explicit JSON-shape instruction for providers that ignore response_format;
  • parse normal or fenced JSON, validate Journal slugs, clamp every field, and degrade usable text to reply with empty optional sections;
  • 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';
  • return the validated response with trace_id, span_id, otel_trace_id, otel_span_id, requested/route/resolved model, provider, request_id, latency, structured-fallback flag, and usage when available;
  • retain the original token SSE response only when response_version is not research-v1;
  • 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, requested/route/resolved metadata, attributes.llm.provider, attributes.llm.router = 9router, the validated response object, and its deterministic text projection.

Required secrets:

ROUTER_BASE_URL=https://your-router.example.com/v1
ROUTER_API_KEY=...
ROUTER_DEFAULT_MODEL=exepert-chat
ROUTER_AFFECT_MODEL=ag/gemini-3.5-flash-extra-low
ROUTER_COMBO_MEMBERS={"exepert-chat":["provider/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.
  • Edge Functions prefer the platform-provided SUPABASE_PUBLISHABLE_KEYS and SUPABASE_SECRET_KEYS dictionaries. Legacy anon/service-role variables are supported only as a migration fallback. The project key is sent in apikey; the signed-in user's JWT remains in Authorization.
  • 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=exepert-chat
pnpm exec supabase secrets set ROUTER_AFFECT_MODEL=ag/gemini-3.5-flash-extra-low
pnpm exec supabase secrets set ROUTER_COMBO_MEMBERS='{"exepert-chat":["provider/model"]}'
pnpm exec supabase functions deploy chat-models
pnpm exec supabase functions deploy run-chat
pnpm exec supabase functions deploy chat-affect
pnpm exec supabase functions deploy chat-feedback

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

Production rollout evidence, migration reconciliation, rollback, provider risk, and the deferred 9Router binary upgrade are recorded in Plan 015.