EXEPERT Brain Chat
The browser and native clients share compatible server contracts, but their interaction surfaces are intentionally separate. See Native desktop Chat for the GPUI composer, ephemeral image-input, saved-prompt, and desktop lifecycle implementation.
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
- 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 verified 9Router catalog through the dedicatedchat-modelsEdge Function. Refresh, stale-cache, and failure states remain distinct. The selected model is stored underexepert.brainChat.model.v1only while it remains in the server catalog.- 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, selects at most two relevant public Journal entries, calls 9Router, and validates one structured response.- 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.
run-chatwrites asessionsrow, atracesrow, and aspansrow. The span stores both the validated JSON and a deterministic text projection.- 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 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-modelsand 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-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 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, andthinkinginto 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_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 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.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, then rejects public titles or messages that contain private infrastructure terminology.src/brain-ui.tsrenders.chat-error-card, tracksmodel_unavailablefailures in memory, disables those model rows for the current browser session, and offersRetry,Use EXEPERT Chat, 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, 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.
Retryresends the same prompt only when the error is retryable.Use EXEPERT Chatswitches back toexepert-chatand 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.
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.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/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=1as 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_idmatches 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/completionsendpoint withstream: false, a strict JSON schema, a 45-second timeout, and an explicit JSON-shape instruction for providers that ignoreresponse_format; - parse normal or fenced JSON, validate Journal slugs, clamp every field, and
degrade usable text to
replywith empty optional sections; - persist the completed turn as:
sessions.session_keyfor the browser chat session;- a
tracesrow namedchat.turn; - a
spansrow named9router.chat.completionwithspan_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_versionis notresearch-v1; - 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, 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", "..."]}
ROUTER_VISION_MODELS=["provider/explicitly-verified-vision-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.
ROUTER_VISION_MODELS is a server-side JSON array, not a naming pattern.
Only routes whose image behavior has been explicitly verified belong in it.
The model catalog advertises image-input-v1; native clients treat a missing
version as text-only and never switch a user's selected model automatically.
The browser composer remains text-only. Native image bytes are ephemeral and
are excluded from transcript persistence, errors, diagnostics, and span input
attributes; only bounded MIME, dimension, count, and byte-size metadata may be
recorded. Image attachment retention uses neither Supabase Storage nor a
database migration. The separate saved-prompts library does add
20260822173118_chat_saved_prompts.sql for prompt text and metadata only.
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. - Chat image diagnostics also redact data URLs, base64 fields, and raw
attachment bytes. Model image support comes only from explicit provider
modality metadata,
ROUTER_VISION_MODELS, or all-known-capable combos. - Edge Functions use the service role key only after validating the user and project ownership.
- Edge Functions prefer the platform-provided
SUPABASE_PUBLISHABLE_KEYSandSUPABASE_SECRET_KEYSdictionaries. Legacy anon/service-role variables are supported only as a migration fallback. The project key is sent inapikey; the signed-in user's JWT remains inAuthorization. - 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 secrets set ROUTER_VISION_MODELS='["provider/explicitly-verified-vision-model"]'
pnpm exec supabase functions deploy chat-saved-prompts
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
Deploy chat-models before run-chat, then distribute the native desktop
client. A live image smoke must use an explicitly authorized Supabase target
and a configured Vision route; local tests alone do not certify provider
forwarding.
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.