you type a prompt
│
▼
POST /api/generate ──► runSelfConsistency()
│ │
│ ┌───────────┼───────────┐
│ ▼ ▼ ▼
│ OpenAI Anthropic Google (parallel, allSettled)
│ │ │ │
│ └───────────┼───────────┘
│ ▼
│ successes → Claude (synthesizer)
│ │
▼ ▼
SSE events stream back: candidate_started/done → synthesis_started/done → final
Every stage is an event, not a silent wait — that's the whole reason this is SSE instead of a single JSON response.
| File | Job |
|---|---|
ai/modelConfig.ts | Env → config. Decides who's available (has a key?) and can we synthesize (Anthropic key present?) |
ai/models.ts | Factory only. Builds the 3 candidate LLMs + the Claude synthesizer. Never calls them. |
ai/prompt.ts | Pulls both system prompts from env (throws at boot if missing). Builds the synthesis message. Edit this to change tone/quality. |
types.ts | The shared contract (CandidateResult, SynthesisResult, GenerateResponse) — frontend & backend agree here |
orchestrator.ts | The brain. Fans out, waits for all, synthesizes the survivors, never throws. |
api/generate/route.ts | Thin HTTP shell — validates input, streams orchestrator events as SSE |
sseClient.ts | Hand-rolled SSE parser on the client — no library |
events.ts | Client-side view types: ModelView, FinalPhase, provider dot colors |
hooks/useThoughts.ts | Client state machine — folds SSE events into UI state |
page.tsx + components/ | Renders it all: prompt box → live model cards → final-answer panel |
useThoughts.generate() opens the SSE streamrunSelfConsistency()Promise.allSettled (a failure never kills the others)thinking → done/failed livefinal event carries the complete response — used only to reconcile stats, never to "jump" the UIallSettled, always. One dead provider ≠ a dead request. all/race/any are all wrong here — see the README for the full combinator breakdown.error, synthesisSkippedReason), so the route stays a dumb pipe.CANDIDATE_SYSTEM_PROMPT / SYNTHESIS_SYSTEM_PROMPT are env-only and the app refuses to boot without them — loud failure beats silent wrong output.AbortSignal.timeout(MODEL_TIMEOUT_MS) wraps every provider call the same way.final only fills in stats it couldn't know earlier.