SPB Git

spb/chat-spboucher Public

Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai

TypeScript 78.8% CSS 15.1% JavaScript 4.9% Shell 1.2%
21.8 KB · 501 lines markdown
Rendered Raw Blame History
1# CLAUDE.md — chat.spboucher.ai23> Author: Simon-Pierre Boucher4> Contact: contact@spboucher.ai5> Project: chat.spboucher.ai6> Deployment target: node **m4m64a** (Apple Silicon, macOS) exposed via **ngrok** at **https://chat.spboucher.ai**78This file is the single source of truth for Claude Code when working on this repository. Read it fully before writing any code. When in doubt, prefer the decisions written here over general best practices.910---1112## 0. What This Project Is1314chat.spboucher.ai is a **private, universal chat interface over the entire OpenRouter ecosystem** — a personal ChatGPT/Claude-class product, not an API demo. One user (Simon-Pierre Boucher) must be able to authenticate from any device (especially a smartphone), pick nearly any model OpenRouter exposes, stream responses smoothly, leave, and resume the same conversation later from another device with full model attribution on every answer.1516---1718## 1. Critical Architectural Decision: OpenRouter-First1920chat.spboucher.ai must use **OpenRouter as the primary and initially exclusive external LLM gateway**.2122**Do not** build separate integrations for Anthropic, OpenAI, Google, xAI, Mistral, DeepSeek, Groq, Together, Fireworks, or any other provider in the initial implementation. OpenRouter already solves that abstraction and gives access to hundreds of continuously updated models behind one API.2324```25chat.spboucher.ai262728 Internal Chat Engine293031   OpenRouter API3233        ├── Anthropic models34        ├── OpenAI models35        ├── Google models36        ├── xAI models37        ├── Meta models38        ├── Qwen models39        ├── DeepSeek models40        ├── Mistral models41        ├── Moonshot models42        └── hundreds of additional models43```4445The UI must remain **model-centric**, never provider-integration-centric.4647For version one:4849- ONE gateway50- ONE API key51- ONE model catalog52- ONE streaming implementation53- HUNDREDS of models5455Exploit this. Do not overengineer provider support.5657---5859## 2. Single External Credential6061Use one server-side credential: `OPENROUTER_API_KEY`.6263The key must:6465- exist **only** server-side (loaded from `.env`, which is gitignored)66- never be exposed to the browser67- never appear in logs68- never be committed to Git69- never be embedded in static JavaScript70- never be returned in API responses7172All LLM requests flow through the application backend. The browser **never** talks to OpenRouter directly.7374```75Browser (phone or desktop)76   │  POST /api/chat7778chat.spboucher.ai backend (node m4m64a)79   │  authenticated request8081OpenRouter → selected model82```8384---8586## 3. Deployment: node m4m64a + ngrok8788The app runs **locally on node m4m64a** and is exposed publicly through an **ngrok tunnel bound to the reserved domain chat.spboucher.ai**.8990### 3.1 Runtime layout9192- Backend + frontend served from a single Node.js process (Next.js recommended, or Node + Vite SSR) listening on `localhost:<PORT>` (default `3000`).93- Database: **SQLite** (better-sqlite3 or Drizzle + SQLite) on m4m64a's local disk, e.g. `~/apps/chat.spboucher.ai/data/chat.db`. No external DB dependency — this is a single-node, single-primary-user product. Enable WAL mode.94- File uploads stored under `~/apps/chat.spboucher.ai/data/uploads/` with content-hash filenames.9596### 3.2 ngrok configuration9798Use an ngrok config file (`~/.config/ngrok/ngrok.yml`) with a named tunnel, not ad-hoc CLI flags:99100```yaml101version: 3102agent:103  authtoken: <NGROK_AUTHTOKEN>   # never commit104tunnels:105  chat:106    proto: http107    addr: 3000108    domain: chat.spboucher.ai109```110111Start with `ngrok start chat`.112113Requirements:114115- The app must trust `X-Forwarded-*` headers from ngrok (set `trust proxy`) so auth cookies, rate limiting, and absolute URLs work correctly.116- Auth cookies: `Secure`, `HttpOnly`, `SameSite=Lax` — TLS terminates at ngrok, so the app must treat itself as HTTPS-fronted.117- Streaming (SSE / ReadableStream) must be verified **through the tunnel on a real phone**: send periodic keep-alive comments (`: ping\n\n`) every ~15s so ngrok and mobile radios don't kill idle streams.118- WebSockets work through ngrok, but prefer **SSE/fetch streaming** for chat — it survives mobile network transitions better.119120### 3.3 Process supervision on macOS121122Run both the app and ngrok as **launchd LaunchAgents** (preferred on macOS) or via `pm2` if simpler:123124- `ai.spboucher.chat.app.plist` → runs the Node server, `KeepAlive: true`, `RunAtLoad: true`, logs to `~/apps/chat.spboucher.ai/logs/app.log`.125- `ai.spboucher.chat.ngrok.plist` → runs `ngrok start chat`, `KeepAlive: true`.126127Provide these plists (or a `pm2 ecosystem.config.js`) plus a `deploy.sh` script in `ops/` that: pulls latest, installs deps, builds, runs DB migrations, restarts the agents. Deployment must be a single command on m4m64a. Include a nightly SQLite backup script (`sqlite3 chat.db ".backup ..."`).128129### 3.4 Security posture for a public tunnel130131Because ngrok makes the app publicly reachable:132133- Every route except `/login` and static assets requires an authenticated session.134- Strong auth from day one: single-user credential + optional TOTP, argon2id password hashing, session tokens in DB, aggressive login rate limiting by IP.135- No unauthenticated API surface. `/api/*` returns 401 without a valid session, always.136- Standard security headers: CSP, X-Content-Type-Options, Referrer-Policy, frame denial.137138---139140## 4. Do Not Hardcode the Model Catalog141142The model catalog must be **dynamically synchronized** from the OpenRouter models endpoint. Never maintain a manual list of hundreds of models.143144Periodically fetch and normalize: model id, display name, provider, context length, input/output modalities, supported parameters, pricing, architecture, description.145146```ts147interface ModelDefinition {148  id: string;                 // exact OpenRouter model ID — opaque149  name: string;150  provider?: string;151  description?: string;152  contextLength?: number;153  pricing?: {154    prompt?: number;155    completion?: number;156    image?: number;157    request?: number;158  };159  capabilities: {160    text: boolean;161    vision: boolean;162    reasoning: boolean;163    tools: boolean;164    structuredOutput: boolean;165  };166  metadata: {167    architecture?: string;168    tokenizer?: string;169    raw?: unknown;170  };171}172```173174- Persist the normalized catalog in SQLite; the model picker loads instantly from cache.175- Sync flow: `OpenRouter → server sync job → database cache → application`. Manual refresh from settings; scheduled refresh every few hours.176- Adding a new OpenRouter model must **never require a deployment**.177- Treat model IDs (`provider/model-name`) as **opaque strings**. Never infer behavior by string-parsing IDs. Store the exact ID in an `openrouter_model_id` column and send it back verbatim.178179### Removed models180181Catalogs change. Historical messages must remain intact: if a past generation used a model that no longer exists, still display its original stored metadata, marked `Unavailable`. Never corrupt old conversations.182183---184185## 5. Model Catalog UX186187With hundreds of models, a basic dropdown is forbidden. Build a **searchable model browser** supporting:188189- fuzzy search190- provider filtering191- favorites (persisted in DB, shown at top)192- recently used models (tracked, shown near top)193- pinned models194- capability filters: reasoning, vision, tool use195- context-window filtering, price filtering, free-model filtering196- sorts: context window, price, alphabetical, recently added197198The picker must remain fast with 300+ entries — use list virtualization. On mobile, the picker opens as a **full-height bottom sheet** with a sticky search field, not a tiny dropdown.199200---201202## 6. Conversations, Model Switching, Branching203204- A conversation never belongs permanently to one model. The user can switch models for any new message.205- Every assistant generation stores: `model_id`, `model_name`, `provider`, `generation_id`, `created_at`. The UI labels each assistant response with the model that produced it. This is critical for auditing and comparing models.206- **Regenerate** on every assistant answer, with: same model, another model, or a favorite. Regeneration **creates a branch** — it never destroys the original answer.207208```209Prompt210 ├── Claude answer211 ├── GPT answer212 └── Gemini answer213```214215- Design the data model so a future **side-by-side comparison mode** (same prompt against N checked models, columns on desktop, swipeable panes on mobile) is possible. Don't fully implement comparison in v1, but the schema must not prevent it.216217---218219## 7. Streaming220221Use OpenRouter streaming for every model that supports it. The experience must be genuinely incremental — never wait for completion before showing output.222223```224request → OpenRouter stream → server stream parser225       → normalized application events → browser ReadableStream226       → incremental rendering227```228229### Internal streaming protocol230231The browser receives **normalized application events**, never raw OpenRouter wire format:232233```ts234type ChatStreamEvent =235  | { type: "generation.start"; generationId: string; model: string }236  | { type: "content.delta"; text: string }237  | { type: "reasoning.delta"; text: string }238  | { type: "tool.start"; toolCallId: string; name: string }239  | { type: "tool.delta"; toolCallId: string; argumentsDelta: string }240  | { type: "usage"; promptTokens?: number; completionTokens?: number; totalTokens?: number; cost?: number }241  | { type: "generation.end" }242  | { type: "generation.error"; message: string; retryable: boolean };243```244245This abstraction leaves the door open for local models, private endpoints, direct provider APIs, or other gateways later — without rewriting the frontend.246247---248249## 8. OpenRouter Client Module250251All OpenRouter interaction lives in one module. **No scattered `fetch()` calls anywhere else.**252253```254src/lib/openrouter/255├── client.ts256├── models.ts257├── stream.ts258├── schemas.ts259├── types.ts260├── errors.ts261├── pricing.ts262├── capabilities.ts263└── index.ts264```265266Every created source file in this repository must begin with:267268```269// Author: Simon-Pierre Boucher270// Contact: contact@spboucher.ai271// Project: chat.spboucher.ai272```273274### Model routing275276OpenRouter can route across providers (provider preference, fallbacks, latency/price optimization, data-policy preferences). Do **not** expose these in the initial UI, but design the request layer so routing configuration can eventually be attached to an individual generation.277278---279280## 9. Errors, Retries, Cancellation, State281282### Error normalization283284Normalize before anything reaches the UI:285286`AUTHENTICATION_ERROR · RATE_LIMIT · MODEL_UNAVAILABLE · PROVIDER_UNAVAILABLE · CONTEXT_TOO_LARGE · INVALID_REQUEST · CONTENT_REJECTED · TIMEOUT · NETWORK_ERROR · UNKNOWN`287288```ts289interface NormalizedAIError {290  code: AIErrorCode;291  message: string;292  retryable: boolean;293  modelId?: string;294  status?: number;295}296```297298Show useful errors without exposing sensitive internals.299300### Retries301302Conservative, bounded exponential backoff for transient cases only (network interruption, temporary upstream failure, selected 5xx). Never retry auth failures, invalid prompts, context-too-large, or non-retryable client errors. Never create infinite retry loops.303304### Cancellation305306"Stop" must abort the backend request **and** the upstream OpenRouter stream via `AbortController`. Never fake it by merely halting rendering while continuing to consume and pay for the generation.307308### Generation state machine309310Each generation is an explicit state machine: `queued → starting → streaming → completed | cancelled | failed`, with a persistent `generation_id`. No grab-bag `isLoading` booleans. This identity powers retry, branching, usage accounting, cancellation, debugging, and analytics.311312---313314## 10. Context Management315316- Use model metadata to know approximate context windows. Estimate conversation token usage **before** each request; never silently exceed the limit.317- Display subtle context usage where useful: `42k / 200k`.318- Database history and model context are **separate concepts**. A conversation may hold thousands of messages; never ship the whole thing to OpenRouter indefinitely.319320```321Conversation322323324Context Compiler325      │ ├── system instructions326      │ ├── memory327      │ ├── recent messages328      │ ├── selected historical messages329      │ ├── file context330      │ └── summaries331332OpenRouter request333```334335The context compiler must be able to: include recent messages, preserve system instructions, retain pinned context, trim low-value older context, and eventually summarize older context.336337---338339## 11. Usage & Cost Tracking340341Capture per generation whenever possible: prompt tokens, completion tokens, reasoning tokens, cached tokens, total tokens, estimated cost, actual reported cost.342343```344generation_usage345----------------346id347generation_id348model_id349prompt_tokens350completion_tokens351reasoning_tokens352cached_tokens353total_tokens354estimated_cost_usd355reported_cost_usd356created_at357```358359### Display360361Each response may show subtle metadata — `Claude Opus · 2.8k tokens · $0.04` — with full details on tap/hover in an inspector. Never clutter the conversation.362363### Usage dashboard364365A settings/usage page with Today / 7 days / 30 days / All time: total requests, input/output/total tokens, total cost, average response cost, most used models, cost by model / provider / conversation. Architect for charts.366367---368369## 12. Mobile-First: The App Must Feel Native on a Smartphone370371This is a hard requirement, not a nice-to-have. The primary usage device is a phone.372373### 12.1 PWA374375- Full **installable PWA**: web app manifest (name, maskable icons, `display: standalone`, theme color per light/dark), service worker.376- Service worker caches the app shell and model catalog for instant cold starts; conversation data stays network-first (single source of truth is the server DB on m4m64a).377- App icon and splash must look deliberate, not default.378379### 12.2 Layout & interaction380381- **Mobile-first CSS**: design for ~390px width first, then progressively enhance to tablet and desktop (where the sidebar becomes persistent).382- Respect safe areas: `viewport-fit=cover` + `env(safe-area-inset-*)` padding for notch and home indicator.383- The composer must handle the mobile keyboard correctly: `dvh` units / VisualViewport API so the input is never hidden behind the keyboard and the message list stays anchored to the newest message.384- Touch targets ≥ 44×44px. Message actions (copy, regenerate, branch, model info) live in a tap/long-press action row — never hover-only menus.385- Conversation list: swipeable left drawer on mobile, persistent sidebar ≥ 1024px.386- Model picker: bottom sheet on mobile (see §5).387- Swipe gestures: swipe between sibling branches of a message; pull-to-refresh on the conversation list.388- Streaming text auto-follows at the bottom; the instant the user scrolls up, auto-follow pauses with a "↓ jump to latest" pill.389- 60fps scrolling on a phone with a 2,000-message conversation: virtualize the message list.390- `prefers-reduced-motion` respected everywhere.391392### 12.3 Mobile network reality393394- SSE keep-alives (§3.2) so streams survive radio sleep.395- If a stream drops mid-generation, the client reconnects and resyncs generation state from the server (the generation state machine makes this possible).396- Optimistic send: the user's message appears instantly, marked pending until the server confirms.397398---399400## 13. Design Direction: Legendary, Not Templated401402The bar: someone seeing a screenshot should recognize this app instantly. It must not look like an AI-generated default, and explicitly **not** like a ChatGPT or Claude clone.403404### 13.1 What to avoid405406Three looks are forbidden because they read as generated defaults: (1) warm cream background + high-contrast serif + terracotta accent; (2) near-black + single acid-green/vermilion accent; (3) broadsheet hairline-rule newspaper layout. Also avoid stock "AI product" purple-gradient glassmorphism, rainbow provider branding, and bouncing-dots loaders.407408### 13.2 Direction — "Instrument Panel"409410The identity concept: this is a **personal instrument for operating hundreds of AI models** — closer to a beautifully machined control surface (studio console, cockpit, high-end synthesizer) than a messaging app. Precision, quiet confidence, dense information handled calmly.411412**Palette (dark-first, with a fully designed light theme):**413414| Token | Hex | Role |415|---|---|---|416| `--ink-950` | `#0E1116` | primary surface (deep blue-graphite, not pure black) |417| `--ink-900` | `#151A22` | raised surfaces: composer, sheets, cards |418| `--ink-700` | `#2A3240` | borders, dividers, inactive strokes |419| `--fog-300` | `#AAB4C3` | secondary text, metadata |420| `--fog-050` | `#EEF2F7` | primary text |421| `--signal-500` | `#3FB6A8` | the single accent — calibrated teal, used **only** for live/active states: streaming indicator, active model, send button, focus rings |422| `--amber-500` | `#E0A458` | cost/usage semantics only: token meters, price chips |423424One accent for "alive", one for "cost". Nothing else gets color. Providers are distinguished by small monochrome glyph badges, never by their brand colors.425426**Typography:**427428- Display/UI: **Söhne** (fallback **Inter**), tight tracking, weights 400/500/600 — labels, buttons, model names.429- Chat body: **Inter** at a generous 16–17px / 1.6 line height on mobile — reading comfort is the product.430- Data/mono: **Berkeley Mono** (fallback **JetBrains Mono**) for code blocks, token counts, model IDs, costs. The mono face is a first-class citizen of the identity: every model ID, cost, and context meter is set in it. That's what makes the app feel like an instrument.431432**Signature element — the Model Rail.** A slim, always-visible strip attached to the composer showing the current model as a machined "cartridge": mono model ID, provider glyph, live context meter (`42k / 200k` as a thin filling bar), and price-per-1M chip. Tapping it opens the model bottom sheet. During streaming, the rail's edge carries a subtle teal signal pulse. Switching models visibly swaps the cartridge with a short mechanical slide. This one element carries the entire identity; everything around it stays quiet and disciplined.433434**Motion:** restrained and purposeful. Streaming cursor is a soft teal caret. Bottom sheets use spring easing (~250ms). Branch switching slides horizontally. No ambient background animation. All motion honors `prefers-reduced-motion`.435436**Message rendering:** first-class Markdown, syntax-highlighted code blocks with copy button and language label, LaTeX (KaTeX), tables with horizontal scroll on mobile. Assistant messages are full-width text blocks with a slim attribution header (mono model name · timestamp · cost chip); user messages are compact right-aligned cards. No avatars.437438**Quality floor (never announce it, always meet it):** responsive down to 320px, visible keyboard focus states, WCAG AA contrast in both themes, reduced motion respected, light theme fully designed (`#F6F8FA` paper, same teal, ink text — not an inverted afterthought).439440### 13.3 Copy voice441442Interface copy is plain, active, specific: "Save changes," not "Submit." Errors state what happened and what to do next — never vague, never apologetic. Empty states invite action ("Pick a model and ask something"). One vocabulary throughout: a "model" is always a model, a "conversation" always a conversation.443444---445446## 14. Future Direct Providers447448OpenRouter is the only provider initially, but keep a narrow gateway abstraction:449450```ts451interface ModelGateway {452  stream(request: GenerationRequest): AsyncIterable<ChatStreamEvent>;453  listModels(): Promise<ModelDefinition[]>;454}455```456457Initial implementation: `OpenRouterGateway`. Future possibilities (do **not** implement now): `LocalGateway`, `MLXGateway` (running directly on m4m64a's Apple Silicon), `PrivateClusterGateway`. The goal is only that the frontend never becomes inseparable from OpenRouter.458459---460461## 15. Product Philosophy462463**Simple externally, powerful internally.**464465The user sees: choose model → write message → receive answer.466467The internals handle: authentication, conversation persistence, context compilation, model metadata, streaming, error handling, usage, cost, branching, cancellation, search, security — without exposing complexity.468469---470471## 16. Development Priority472473Implement strictly in this order. Do not start secondary features before the fundamental chat loop is exceptionally stable.4744751. Authentication (single user, session cookies, rate-limited login)4762. Database (SQLite schema + migrations)4773. OpenRouter server client (`src/lib/openrouter/`)4784. Dynamic model catalog (sync + cache)4795. Conversation persistence4806. Chat UI (mobile-first shell, per §12–13)4817. Streaming (SSE through ngrok, verified on a real phone)4828. Stop generation4839. Markdown + code + LaTeX rendering48410. Model search (browser/bottom sheet)48511. Favorites + recent models48612. Message regeneration48713. Conversation branching48814. Search history48915. Usage/cost tracking + dashboard49016. Settings49117. File/image support (vision-capable models)49218. Advanced context manager49319. PWA polish (install, offline shell, icons/splash)49420. Ops hardening (launchd agents, deploy.sh, log rotation, nightly DB backup)495496---497498## 17. Definition of Success499500The first production release is successful when Simon-Pierre Boucher can open **https://chat.spboucher.ai on his phone**, authenticate securely through the ngrok tunnel to m4m64a, install the app to his home screen, select almost any model exposed through OpenRouter from a fast, beautiful bottom-sheet picker, send a message, immediately receive a smooth streaming response even on cellular, leave the application, return later from another device, find the complete conversation, continue it with the same or a completely different model, and inspect which model generated every answer and what each one cost — inside an interface distinctive enough that no one could mistake it for anything else.501