1.0 workstreams A–G: chat/composer, model picker & catalog, Arena, projects/prompts/library, usage/providers/endpoints, marketing/onboarding, search/share/export
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
121 changed files +15,033 −2,891
modified
.env.example
+7 −0
@@ -33,6 +33,13 @@ KIMI_API_KEY= | |||||
| 33 | OPENROUTER_API_KEY= | 33 | OPENROUTER_API_KEY= | ||
| 34 | CEREBRAS_API_KEY= | 34 | CEREBRAS_API_KEY= | ||
| 35 | 35 | ||||
| 36 | +# --- Custom OpenAI-compatible endpoints (Settings → Endpoints) ----------- | ||||
| 37 | +# The PolyLLM *server* calls user-defined endpoints. Private/loopback/link-local | ||||
| 38 | +# addresses (localhost, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1, fc00::/7…) | ||||
| 39 | +# are rejected to prevent SSRF. Set to 1 only for a self-hosted PolyLLM that runs | ||||
| 40 | +# next to Ollama / LM Studio / vLLM on the same machine or LAN. | ||||
| 41 | +ALLOW_PRIVATE_ENDPOINTS=0 | ||||
| 42 | + | ||||
| 36 | # --- Admin --------------------------------------------------------------- | 43 | # --- Admin --------------------------------------------------------------- | ||
| 37 | # Comma-separated emails with access to /admin. | 44 | # Comma-separated emails with access to /admin. | ||
| 38 | ADMIN_EMAILS= | 45 | ADMIN_EMAILS= | ||
modified
docs/deployment-cluster.md
+14 −14
@@ -1,17 +1,18 @@ | |||||
| 1 | # PolyLLM — cluster deployment (MacLustr) | 1 | # PolyLLM — cluster deployment (MacLustr) | ||
| 2 | 2 | ||||
| 3 | −Production runs on the owner's private Apple Silicon cluster and is exposed through ngrok at | 3 | +Production runs on the owner's private Apple Silicon cluster and is exposed through the **MacLustr Tunnel** | ||
| 4 | −**https://www.polyllm.io**. Everything is orchestrated by `mld` (maclustr-dispatch) from the gateway node M1M32. | 4 | +(WireGuard + Caddy on the OVH gateway BHS64, since 2026-09-10 — ngrok is gone) at **https://www.polyllm.io**. | ||
| 5 | +Everything is orchestrated by `mld` (maclustr-dispatch) from the gateway node M1M32. | ||||
| 5 | 6 | ||||
| 6 | ``` | 7 | ``` | ||
| 7 | −Internet → www.polyllm.io (CNAME → ngrok) → ngrok agent (PM2 polyllm-ngrok) → Next.js `next start` 0.0.0.0:8240 → PostgreSQL 17 (localhost) | 8 | +Internet → www.polyllm.io (A → 51.161.112.61, BHS64) → Caddy TLS → WireGuard wg1 → M3U96a:8240 Next.js `next start` → PostgreSQL 17 (localhost) | ||
| 8 | ``` | 9 | ``` | ||
| 9 | 10 | ||||
| 10 | | Item | Value | | 11 | | Item | Value | | ||
| 11 | | --- | --- | | 12 | | --- | --- | | ||
| 12 | | Node | M3U96a (pinned: local PostgreSQL 17, same host as fetcha / spinza / rareindex) | | 13 | | Node | M3U96a (pinned: local PostgreSQL 17, same host as fetcha / spinza / rareindex) | | ||
| 13 | | Directory | `~/apps/polyllm` (rsync'd by mld from the laptop staging area) | | 14 | | Directory | `~/apps/polyllm` (rsync'd by mld from the laptop staging area) | | ||
| 14 | −| Processes | PM2 `polyllm-web` (Next 16, port 8240, binds 0.0.0.0), PM2 `polyllm-ngrok` (`--domain www.polyllm.io`) | | 15 | +| Processes | PM2 `polyllm-web` (Next 16, port 8240, binds 0.0.0.0). Public route = Caddy on BHS64 (`mld tunnel route polyllm`), repointed automatically on deploy/move | | ||
| 15 | | Database | `postgres://localhost:5432/polyllm` (created by the post-sync hook if missing) | | 16 | | Database | `postgres://localhost:5432/polyllm` (created by the post-sync hook if missing) | | ||
| 16 | | Manifest | `M1M32:~/dispatch/apps/polyllm.json` — **the only place secrets live** (gitignored copy `deploy/polyllm.mld.json`) | | 17 | | Manifest | `M1M32:~/dispatch/apps/polyllm.json` — **the only place secrets live** (gitignored copy `deploy/polyllm.mld.json`) | | ||
| 17 | | Health | `GET /api/health` (liveness), `GET /api/health/ready` (DB ping; used by mld) | | 18 | | Health | `GET /api/health` (liveness), `GET /api/health/ready` (DB ping; used by mld) | | ||
@@ -38,26 +39,25 @@ scp deploy/polyllm.mld.json M1M32:~/dispatch/apps/polyllm.json # first time / | |||||
| 38 | 39 | ||||
| 39 | `mld deploy` runs the manifest hooks on the node: `pnpm install --frozen-lockfile` → `createdb polyllm` (if needed) + | 40 | `mld deploy` runs the manifest hooks on the node: `pnpm install --frozen-lockfile` → `createdb polyllm` (if needed) + | ||
| 40 | `pnpm db:migrate` (Drizzle migrations in `drizzle/`, version-controlled) → `pnpm build` → PM2 start/reload of | 41 | `pnpm db:migrate` (Drizzle migrations in `drizzle/`, version-controlled) → `pnpm build` → PM2 start/reload of | ||
| 41 | −`polyllm-web` and `polyllm-ngrok` → local health check (`/api/health/ready`) → public health check (`https://www.polyllm.io/api/health/ready`) → registry update. | 42 | +`polyllm-web` → local health check (`/api/health/ready`) → tunnel route `https://www.polyllm.io → M3U96a:8240` → public health check → registry update. | ||
| 42 | 43 | ||||
| 43 | Restart / logs on the node: | 44 | Restart / logs on the node: | ||
| 44 | 45 | ||||
| 45 | ```bash | 46 | ```bash | ||
| 46 | ssh M3U96a 'pm2 restart polyllm-web; pm2 logs polyllm-web --lines 100 --nostream' | 47 | ssh M3U96a 'pm2 restart polyllm-web; pm2 logs polyllm-web --lines 100 --nostream' | ||
| 47 | −ssh M3U96a 'pm2 logs polyllm-ngrok --lines 30 --nostream' | 48 | +~/Desktop/cluster-skill/mld tunnel status | grep polyllm | ||
| 48 | ``` | 49 | ``` | ||
| 49 | 50 | ||||
| 50 | Migrations are applied by the hook before the new build starts; they must stay backward compatible with the running | 51 | Migrations are applied by the hook before the new build starts; they must stay backward compatible with the running | ||
| 51 | version (add columns, never drop in the same release). | 52 | version (add columns, never drop in the same release). | ||
| 52 | 53 | ||||
| 53 | −## ngrok / domain / TLS | 54 | +## Domain / TLS (MacLustr Tunnel) | ||
| 54 | 55 | ||||
| 55 | −- DNS (GoDaddy): `www.polyllm.io` CNAME → the ngrok-cname target shown in the ngrok dashboard for the reserved domain. | 56 | +- DNS (GoDaddy): `www.polyllm.io` A → `51.161.112.61` (BHS64). The apex `polyllm.io` redirects to `https://www.polyllm.io`. | ||
| 56 | − The apex `polyllm.io` should redirect to `https://www.polyllm.io` (GoDaddy forwarding). | 57 | +- TLS terminates on Caddy (Let's Encrypt, automatic). The app sees `X-Forwarded-For` / `X-Forwarded-Proto`; Better Auth is | ||
| 57 | −- TLS terminates at ngrok (automatic certificate for the reserved domain). The app sees `X-Forwarded-For` / | 58 | + configured with `baseURL = PUBLIC_APP_URL` and secure cookies in production. | ||
| 58 | − `X-Forwarded-Proto`; Better Auth is configured with `baseURL = PUBLIC_APP_URL` and secure cookies in production. | 59 | +- Streaming: SSE responses send a `: ping` heartbeat every 15 s so idle long reasoning turns are never closed by proxies. | ||
| 59 | −- The PM2 process `polyllm-ngrok` runs `ngrok http 8240 --domain www.polyllm.io`. Only port 8240 is exposed; no admin port. | 60 | +- `mld heal` (every 5 min on M1M32) re-creates the wg1 peer / Caddy route if they disappear after an outage. | ||
| 60 | −- Streaming: SSE responses send a `: ping` heartbeat every 15 s so ngrok never closes idle long reasoning turns. | ||||
| 61 | 61 | ||||
| 62 | ## Process management | 62 | ## Process management | ||
| 63 | 63 | ||||
@@ -87,5 +87,5 @@ If a migration must be reverted, restore last night's dump (see Backups) — Dri | |||||
| 87 | 87 | ||||
| 88 | ## Moving to another node | 88 | ## Moving to another node | ||
| 89 | 89 | ||||
| 90 | −`mld move polyllm --to <node>` handles rsync, PM2 and ngrok, but the database is local: dump on M3U96a, restore on the | 90 | +`mld move polyllm --to <node>` handles rsync, PM2 and the tunnel route, but the database is local: dump on M3U96a, restore on the | ||
| 91 | target (PostgreSQL 17 via Homebrew), then update `DATABASE_URL` in the manifest before the move. | 91 | target (PostgreSQL 17 via Homebrew), then update `DATABASE_URL` in the manifest before the move. | ||
added
docs/upgrade-notes/A-chat.md
+127 −0
@@ -0,0 +1,127 @@ | |||||
| 1 | +# Workstream A — Chat & composer (2026-09-11) | ||||
| 2 | + | ||||
| 3 | +Everything in section A of `docs/UPGRADE-PLAN.md` is implemented except the items listed under **Coming soon / limits**. | ||||
| 4 | +`pnpm typecheck`, `pnpm lint` and `pnpm test` are green for every file below (remaining failures at the time of writing | ||||
| 5 | +belong to other workstreams: `arena-view.tsx`, `app/app/onboarding/page.tsx`, `tests/unit/public-models.test.ts`). | ||||
| 6 | + | ||||
| 7 | +## Files | ||||
| 8 | + | ||||
| 9 | +### Server (`lib/chat/*`, `app/api/chat/*`) | ||||
| 10 | +| File | Change | | ||||
| 11 | +| --- | --- | | ||||
| 12 | +| `src/lib/chat/schemas.ts` | `chatRequestSchema` + `ephemeral`, `history`, `projectId`; new `chatAdoptSchema` (`ephemeralHistorySchema` exported). | | ||||
| 13 | +| `src/lib/chat/service.ts` | Ephemeral turns (no conversation/message rows; usage record with `conversationId: null`; temp attachments deleted after the turn), `projectId` on new conversations, richer stored error (`StoredMessageError`: provider, status, retryAfterMs, providerCode, detail), `settings` exposed on `PublicMessage`, new `adoptMessage()` ("Continue with this model"), `EPHEMERAL_CONVERSATION_ID`. | | ||||
| 14 | +| `src/app/api/chat/route.ts` | `meta` event now carries `ephemeral` and `requestId`. | | ||||
| 15 | +| `src/app/api/chat/adopt/route.ts` | **New** `POST /api/chat/adopt`. | | ||||
| 16 | +| `src/lib/chat/humanize-error.ts` | **New** pure error humanizer (`humanizeChatError`, `retryPhrase`, `secondsLeft`). | | ||||
| 17 | +| `src/lib/chat/markdown-blocks.ts` | **New** streaming Markdown splitter (`splitStreamingMarkdown`), `normalizeMath`, `extensionForLanguage`. | | ||||
| 18 | +| `src/lib/chat/deprecation.ts` | **New** `deprecationNotice`, `suggestReplacement`, `largerContextModel`. | | ||||
| 19 | +| `src/lib/client/types.ts` | Appended `ChatMetaExtras`, `StoredMessageError` re-export, `ChatAdoptResponse`. | | ||||
| 20 | +| `tests/unit/chat-client.test.ts` | **New** 18 unit tests (humanizer, splitter, math normaliser, deprecation, schemas, stored error). | | ||||
| 21 | + | ||||
| 22 | +### Client (`components/chat/*`, `components/markdown/*`) | ||||
| 23 | +| File | Change | | ||||
| 24 | +| --- | --- | | ||||
| 25 | +| `chat-view.tsx` | Rewritten orchestrator: mobile header (48 px, hamburger, "more" ActionSheet), Smart Router AUTO flow, cost confirm, temporary chat, project instructions, compare inline, message actions (retry with model, quote, save as prompt, export…), summarize context, deprecated banner, ConfirmDialog for delete (no `window.confirm`). | | ||||
| 26 | +| `composer.tsx` | Redesign `[+] Ask anything… [mic] [send]`; `+` = ActionSheet (phone) / dropdown (desktop) with Upload image / Upload document / Camera (`capture="environment"`, coarse pointers) / Paste content / From library (`FileLibraryPicker`) + `extraActions`; auto-grow 1→6 lines + collapse chevron; drag-and-drop, paste files, multiple chips; voice dictation (hidden when unsupported); `forwardRef` handle `{ focus, openFilePicker, insert }`; `topSlot`/`bottomSlot`. Backwards compatible with the Arena's usage. | | ||||
| 27 | +| `composer-sheets.tsx` | **New** `StructuredOutputSheet` (JSON / JSON schema editor), `SystemPromptSheet`, `ToolsSheet` (calculator/clock/random). | | ||||
| 28 | +| `context-indicator.tsx` | **New** `43K / 200K` meter + cost line; banner at 80 / 95 / >100 % with New chat / Summarize context / Switch to larger-context model. | | ||||
| 29 | +| `router-card.tsx` | **New** Smart Router recommendation sheet (Recommended · Why · Estimated cost · alternatives · mode · Use / Choose another / Always auto-route). | | ||||
| 30 | +| `cost-confirm.tsx` | **New** confirm sheet above `COST_CONFIRM_THRESHOLD_USD` with up to 3 cheaper alternatives (one tap = switch + send). | | ||||
| 31 | +| `model-launcher.tsx` | **New** `ModelPickerLauncher` — opens `ModelSelector` programmatically using only its public props (hidden trigger + `.click()`). | | ||||
| 32 | +| `message.tsx` | Toolbar (desktop hover via `.hover-reveal`, phone long-press → ActionSheet), "more" dropdown, expandable **Details** metadata (model, provider, tokens in/out/reasoning/cached, cost, TTFT, tok/s, reasoning effort, finish reason), deprecated / retiring badge + "Switch to …" strip, humanized errors. | | ||||
| 33 | +| `message-error.tsx` | **New** error card: title/description per code, Retry with live countdown (`retryAfterMs`), Switch model, Providers link, View details sheet (code, provider code, HTTP status, request id, provider message, copy). | | ||||
| 34 | +| `compare-inline.tsx` | **New** inline **Compare with…** (2–4 models via `ModelSelector multiple`; runs `POST /api/arena` + `/api/arena/stream`; desktop grid, phone `.snap-row` + sticky tabs; "Continue with <model>" → `/api/chat/adopt`). | | ||||
| 35 | +| `empty-state.tsx` | "What do you want to work on?" + Write / Code / Research / Analyze a file / Compare models; temporary & project variants; keeps `<Onboarding />`. | | ||||
| 36 | +| `use-speech.ts` | **New** `useSpeechDictation` (Web Speech API, interim + final). | | ||||
| 37 | +| `markdown/markdown.tsx` | Memoized blocks while streaming (completed blocks never re-parse), LaTeX via `remark-math` + `rehype-katex` (+ `\(…\)` / `\[…\]` normalisation), katex CSS imported here, `skipHtml` kept. | | ||||
| 38 | +| `markdown/code-block.tsx` | Copy / Download / Wrap / Expand (collapses > 28 lines), debounced highlighting while streaming, 44 px targets. | | ||||
| 39 | + | ||||
| 40 | +New dependencies: `remark-math`, `rehype-katex`, `katex` (allowed by the plan; katex CSS ≈ 23 KB gz + fonts loaded lazily by the browser). | ||||
| 41 | + | ||||
| 42 | +## API changes | ||||
| 43 | + | ||||
| 44 | +### `POST /api/chat` (SSE) — request additions | ||||
| 45 | +```jsonc | ||||
| 46 | +{ | ||||
| 47 | + "modelKey": "openai/gpt-5.5", | ||||
| 48 | + "message": { "text": "…", "attachmentIds": ["att_…"] }, | ||||
| 49 | + "ephemeral": true, // optional — temporary chat | ||||
| 50 | + "history": [ // optional, ephemeral only (no server history) | ||||
| 51 | + { "role": "user", "content": "…" }, { "role": "assistant", "content": "…" } | ||||
| 52 | + ], | ||||
| 53 | + "projectId": "prj_…" // optional — set on the new conversation only | ||||
| 54 | +} | ||||
| 55 | +``` | ||||
| 56 | +- `ephemeral: true` requires `action: "send"` and no `conversationId`. Nothing is written to `conversations` / `messages`; | ||||
| 57 | + a `usage_records` row is still inserted (`conversation_id` and `message_id` NULL, `kind: "chat"`). Attachments uploaded for the | ||||
| 58 | + temporary turn are deleted after the answer. | ||||
| 59 | +- `meta` event: `{ type: "meta", conversationId, isNewConversation, userMessage, assistantMessageId, modelKey, clientId, | ||||
| 60 | + ephemeral: boolean, requestId: string }` — for ephemeral turns `conversationId` is the sentinel `"ephemeral"`. | ||||
| 61 | +- `done.message.error` (when failed) is now `{ code, message, provider?, status?, retryable?, retryAfterMs?, providerCode?, detail? }`. | ||||
| 62 | +- `PublicMessage` gains `settings` (generation settings used for that assistant turn). | ||||
| 63 | + | ||||
| 64 | +### `POST /api/chat/adopt` — new | ||||
| 65 | +Body: | ||||
| 66 | +```jsonc | ||||
| 67 | +{ | ||||
| 68 | + "conversationId": "cnv_…", // optional; omitted → creates the conversation | ||||
| 69 | + "modelKey": "anthropic/claude-sonnet-5", | ||||
| 70 | + "arenaResponseId": "arr_…", // preferred: content + metrics copied server-side (ownership checked) | ||||
| 71 | + "content": "…", // fallback when no arenaResponseId | ||||
| 72 | + "userText": "…", // required when creating the conversation | ||||
| 73 | + "systemPrompt": "…", "settings": { … }, "projectId": "prj_…" // new conversation only | ||||
| 74 | +} | ||||
| 75 | +``` | ||||
| 76 | +→ `201 { conversation: PublicConversation, userMessage: PublicMessage | null, message: PublicMessage, isNewConversation }`. | ||||
| 77 | +Switches the conversation's model, updates aggregates (`messageCount`, totals). No provider call — usage was recorded by the Arena. | ||||
| 78 | + | ||||
| 79 | +### Consumed (built by other workstreams — degrade gracefully) | ||||
| 80 | +- `GET /api/projects/:id` → `{ project: { id, name, instructions, preferredModelKeys } }` (D). 404 / network error → no instructions. | ||||
| 81 | +- `POST /api/prompts` `{ name, content, source: "chat" }` (D). 404/405 → toast "Prompt library not available yet". | ||||
| 82 | +- `GET /api/prompts/:id` → `{ prompt: { content } }` for `?promptId=` (D). Ignored on 404. | ||||
| 83 | +- `window.dispatchEvent(new CustomEvent("polyllm:prompt-insert", { detail: { text } }))` inserts text at the composer caret (D / G). | ||||
| 84 | +- `localStorage["polyllm:router-mode"]` (RouterMode) and `localStorage["polyllm:router-always"]` (boolean) — B's AUTO entry may read/write the mode. | ||||
| 85 | + | ||||
| 86 | +## Behaviour notes | ||||
| 87 | +- **AUTO**: `selectedModelKey === AUTO_MODEL_KEY` → pill shows "Auto ▾"; on send the Router card appears (Recommended · Why · | ||||
| 88 | + Estimated cost · alternatives · mode). "Always auto-route" skips the card but always toasts *Auto-routed to X · why* and the | ||||
| 89 | + message header shows the model. The conversation then keeps the concrete model; the global selection stays AUTO. | ||||
| 90 | +- **Cost confirm**: estimate > `COST_CONFIRM_THRESHOLD_USD` → sheet with ≤ 3 cheaper models from `routeModels(…, "cheapest")`. | ||||
| 91 | + Cancelling any sheet restores the draft and attachments. | ||||
| 92 | +- **Temporary chat**: `?temporary=1` or `+ → Temporary chat`. Header badge "Temporary chat — not stored in history". Retry / edit are | ||||
| 93 | + emulated client-side (history replayed); Continue and Branch are hidden. Refreshing the page loses the messages (by design). | ||||
| 94 | +- **Summarize context**: real — an ephemeral turn with the current model produces the summary, then `POST /api/conversations` | ||||
| 95 | + creates "<title> (continued)" with the summary appended to the system prompt and navigates to it. | ||||
| 96 | +- **Project instructions**: for a new chat with `activeProjectId`, `project.instructions` are prepended to the system prompt on the | ||||
| 97 | + wire (the conversation stores the merged prompt from then on) and `preferredModelKeys[0]` becomes the default model unless the | ||||
| 98 | + user already picked one. `projectId` is sent with the first turn. | ||||
| 99 | +- **Errors**: `MessageError` maps every `PolyErrorCode` (+ `NO_PROVIDER_KEY`) to copy such as "OpenAI rate limit reached. Retry in 18 seconds." | ||||
| 100 | + with a live countdown; details sheet shows code / provider code / HTTP status / request id. | ||||
| 101 | +- **Streaming**: 40 ms batching kept; `Markdown streaming` renders completed blocks through memoized `<Block>`s (cuts only on blank | ||||
| 102 | + lines outside fences, never splitting loose lists / quotes / tables), the tail re-parses live; code highlighting is debounced 120 ms. | ||||
| 103 | + | ||||
| 104 | +## Coming soon / limits | ||||
| 105 | +- Nothing is labelled "Coming soon" in the UI. Known limits: | ||||
| 106 | + - Temporary chats: attachments from earlier turns are not replayed on retry/edit (text history only). | ||||
| 107 | + - `⌘/` is a global listener inside `ModelSelector`; while the inline Compare panel (second selector) or the Router card is open the | ||||
| 108 | + shortcut toggles both instances. `TODO(integration: B)`: expose `open`/`onOpenChange` on `ModelSelector` so `model-launcher.tsx` | ||||
| 109 | + can drop the hidden-trigger `.click()` workaround. | ||||
| 110 | + - `ModelConfig` is opened from the mobile "more" sheet by clicking its trigger (`#chat-model-config`). | ||||
| 111 | + - Model labels (`store.labels`) are not shown on the pill — left to B's `ModelSelector`. | ||||
| 112 | + | ||||
| 113 | +## Visual QA checklist (integration phase, 375 / 390 / 393 / 430 / 1440) | ||||
| 114 | +1. New chat: header 48 px with hamburger; empty state "What do you want to work on?" with 5 quick actions in 2 columns (5 on lg); no horizontal overflow. | ||||
| 115 | +2. Composer: single row `[+] Ask anything… [mic?] [send]`; textarea grows to 6 lines then scrolls; chevron collapses/expands; 16 px font on phones (no iOS zoom); keyboard open → composer stays above it (`.h-app`), no page scroll. | ||||
| 116 | +3. `+` sheet on phone (ActionSheet) / dropdown on desktop; Camera item only on touch devices with a vision model; Tools / Web search / Structured output only when the model supports them; Temporary chat check mark. | ||||
| 117 | +4. Attachments: drag-and-drop highlight, paste image, chips with remove, upload indicator; From library opens `FileLibraryPicker`. | ||||
| 118 | +5. Model pill above the composer opens the picker; AUTO shows "Auto ▾"; Router card on send (sheet on phone) with alternatives and mode segmented; Choose another opens the picker; Always auto-route persists. | ||||
| 119 | +6. Context indicator: `43K / 200K` + bar; simulate 80 % / 95 % (long paste) → warning / critical banner with New chat, Summarize context, Switch to larger model. | ||||
| 120 | +7. Cost confirm sheet when estimate > $1 (paste a very long text on an expensive model); one-tap alternative switches and sends. | ||||
| 121 | +8. Temporary chat via `?temporary=1`: badge in header, no URL change after send, no entry in the sidebar; usage still appears in Usage. | ||||
| 122 | +9. Messages: hover toolbar on desktop, long-press ActionSheet on phone (copy, retry, retry with another model, compare, quote, save as prompt, export, delete); Details disclosure shows the metadata grid; deprecated model → badge + "Switch to …". | ||||
| 123 | +10. Error card: force a bad key → "… rejected your API key." with Providers link; force 429 → countdown on Retry; View details sheet. | ||||
| 124 | +11. Streaming: long Markdown answer with tables, code blocks and LaTeX (`$$\int_0^1 x\,dx$$`) — no flicker of completed blocks; code block Copy / Download / Wrap / Expand. | ||||
| 125 | +12. Compare with…: from a response (prompt pre-filled) and from the empty state (editable prompt); desktop grid 2–4 columns, phone swipeable panels with sticky tabs; "Continue with <model>" appends the answer and switches the pill; new-chat case navigates to the created conversation. | ||||
| 126 | +13. Header "more" sheet on phone: Model settings, Compare, Pin, Share, New temporary chat, Open Arena, Delete (ConfirmDialog). | ||||
| 127 | +14. Scroll-to-bottom pill appears when scrolled up; Enter / ⇧Enter / ⌘Enter / Esc / ⌘/ shortcuts still work. | ||||
added
docs/upgrade-notes/B-models.md
+74 −0
@@ -0,0 +1,74 @@ | |||||
| 1 | +# Workstream B — Models (2026-09-11) | ||||
| 2 | + | ||||
| 3 | +Model picker, badges, profile, catalog, public SEO pages, capability-aware settings and presets. | ||||
| 4 | +Everything derives from real registry data (`PolyModel`); nothing is invented — unknown release dates and | ||||
| 5 | +cutoffs render as "—". | ||||
| 6 | + | ||||
| 7 | +## Files | ||||
| 8 | + | ||||
| 9 | +### New — pure libraries (`src/lib/models/*`, client- and server-safe, unit-tested) | ||||
| 10 | +| File | Exports | | ||||
| 11 | +| --- | --- | | ||||
| 12 | +| `badges.ts` | `buildBadgeContext(models, now)` (pricing quantiles, "new" window), `deriveBadges(model, allModels \| ctx)`, `lifecycleStatus(model, {connected, ctx, now})`, `isFastModel/isCheapModel/isCodingModel/isOpenWeightsModel/isLongContextModel/isNewModel`, `speedTier`, `blendedPrice`, `shutdownDateOf`, `retiresSoon`, `sortWeightOf`, regexes `FAST_RE/FRONTIER_RE/CODING_RE/OPEN_WEIGHTS_RE` | | ||||
| 13 | +| `search.ts` | `parseSearchQuery(q) → SearchIntent` (brands, capabilities, min context, max $/M, open weights, coding, newest, quality, sort, chips), `searchModels(models, q \| intent, ctx) → SearchResult[]` (hard filters + ranked text/intent score), `isEmptyIntent` | | ||||
| 14 | +| `slug.ts` | `slugify`, `modelSlug`, `compareSlug`, `parseCompareSlug`, `findModelBySlug`, `resolveCompareSlug`, `MAX_COMPARE` | | ||||
| 15 | +| `params.ts` | `PARAM_DEFS` (17 unified settings × group × `supports(model)` × detail), `SETTINGS_GROUPS`, `groupsFor`, `supportsParam`, `isSetValue`, `unsupportedSettings`, `presetCompatibility`, `presetToSettings`, `pruneSettingsForModel` | | ||||
| 16 | +| `sections.ts` | `buildSelectorSections` (Favorites, Recent, Recommended, Fast, Best reasoning, Cheapest, Largest context, Vision, Coding, Open source, New) and `recommendedModels` (wraps `routeModels`) | | ||||
| 17 | +| `format.ts` | `formatPrice`, `formatPricePair`, `formatContext`, `formatIsoDate` | | ||||
| 18 | +| `public-data.ts` | `server-only`: `getPublicModels()` (React `cache`, graceful `ok:false` when DB is down), `flagshipModels`, `popularComparisons` | | ||||
| 19 | + | ||||
| 20 | +### New — components | ||||
| 21 | +- `src/components/models/model-profile.tsx` — `ModelProfile` (ResponsiveDialog, snap full): header + user label (set / remove), lifecycle & derived badges, 12 facts (context, max out, in/out/cached $, long-context tier, speed tier, release date, knowledge cutoff, provider listing date, first seen, prices as of), capability grid, supported parameters grouped with ranges/levels, lifecycle block, aliases/source. Footer: **Compare** (`/app/models/compare?m=`), **Arena** (`/app/arena?models=key`), **Use in chat** (or Connect). | ||||
| 22 | +- `src/components/models/compare-table.tsx` — `CompareTable` (server-safe, no hooks): header cards, spec rows with meters (context, max out, input/output $), capability matrix, parameter matrix. Desktop grid / phone stacked cards + compact check grid. | ||||
| 23 | +- `src/components/models/meter.tsx` — single-hue 4 px magnitude bar (values always printed beside it). | ||||
| 24 | +- `src/components/models/badge-chips.tsx` — `BadgeChips`, `LifecycleChip` (presentational, shared by app and public pages). | ||||
| 25 | + | ||||
| 26 | +### Rewritten | ||||
| 27 | +- `src/components/chat/model-selector.tsx` — same export/props (`value, onChange, className, size, allowDisconnected, multiple, selected, onToggle, buttonLabel`) **+ `showAuto?`, `trigger?`, `draft?`**. Phone: BottomSheet snap full with fixed search header; desktop: dialog (680 px). Sections + "All models" by provider, always virtualized (`@tanstack/react-virtual`, rows 56 px, dynamic measure). AUTO card with router mode `Segmented` (persisted `localStorage["polyllm:router-mode"]` through `useLocalStorage` → JSON string, e.g. `"balanced"`). Intent search with "Understood:" chips. Rows: provider icon, name + accent label, lifecycle chip, badges, ctx · $in/$out, capability glyphs, star (`animate-pop`), "…" menu (desktop DropdownMenu / phone long-press or "…" → ActionSheet): Favorite, Set/Change/Remove label (PromptDialog → `store.setLabel`), View profile, Compare. Keyboard ↑↓↵ (combobox pattern, `aria-activedescendant`). ⌘/ toggles. | ||||
| 28 | +- `src/components/chat/model-badges.tsx` — `CAPABILITY_GLYPHS`, `CapabilityGlyphs`, `ModelBadges` (NEW/REASONING/VISION/CODING/FAST/CHEAP/LONG CONTEXT), `StatusBadge({ model, connected, ctx, showActive })` (New / Active / Preview / Deprecated / Retiring < 90 d / Unavailable / Unverified), `useBadgeContext()`; kept `capabilityList`, `CapabilityBadges`, `PriceLabel`, `isFastModel`, `retiresSoon` for existing callers (empty-state, arena/types). | ||||
| 29 | +- `src/components/chat/model-config.tsx` — same props (`model, settings, onChange, systemPrompt, onSystemPromptChange, trigger, allowSavePreset`). ResponsiveDialog: phone full sheet, desktop right **panel**. Collapsible groups Generation / Reasoning / Output / Tools / Advanced built from `groupsFor(model)`; sliders with numeric inputs + per-control reset; Segmented for small enums; Reset to defaults; **Save preset** (PromptDialog → `POST /api/presets?kind=model`); **Apply preset** (sheet listing presets, compatible first; incompatible ones list every reason from `presetCompatibility`). `countActiveSettings` kept. | ||||
| 30 | +- `src/app/app/models/page.tsx` — catalog: mobile 48 px bar (hamburger, refresh), intent search, ChipRow filters (Reasoning, Vision, Tools, JSON, Web, PDF, Fast, Cheap, Open source, New), provider chips, status Segmented (Deprecated fetches `/api/models?deprecated=1`), Connected only / Favorites switches, sortable columns (Model, Provider, Input, Output, Context, Max out, Reasoning, Vision, Tools, Speed tier, Status; `aria-sort`), phone sort Select, virtualized rows (stacked below `md`, grid table from `md`, extra columns from `lg`), inline price/context meters, select up to 4 → compare bar → `/app/models/compare?m=a,b,c`, row → `ModelProfile`. Sync/refresh + favorites unchanged. | ||||
| 31 | +- `src/app/app/presets/page.tsx` — editor moved to `ResponsiveDialog` (sheet on phones), pruning now uses `pruneSettingsForModel` (effort-level aware). `components/presets/*` unchanged. | ||||
| 32 | + | ||||
| 33 | +### New — pages | ||||
| 34 | +- `src/app/app/models/compare/page.tsx` — arbitrary sets (`?m=key1,key2[,…]`, max 4), add/remove via `ModelSelector multiple allowDisconnected`, `CompareTable`, "Try in Arena". | ||||
| 35 | +- `src/app/(marketing)/models/page.tsx` — public catalog (server component, `force-dynamic`, no auth): per-provider tables (desktop) / rows (mobile), badges, lifecycle, popular comparisons, sign-up CTA, honest empty state when the registry is unreachable. | ||||
| 36 | +- `src/app/(marketing)/compare/[slug]/page.tsx` — `gpt-5.5-vs-claude-sonnet-5` (2–4 parts, ids or display names, provider-prefixed ids for gateways), `generateMetadata` (title/description/canonical/OG), `notFound()` on unknown/duplicate, `CompareTable`, related comparisons, "Try both in Arena" → `/app/arena?models=…`. | ||||
| 37 | + | ||||
| 38 | +### Additive | ||||
| 39 | +- `src/lib/ai/registry/index.ts` — `rowToModel` now emits `metadata.firstSeenAt` (ISO) next to `lastSeenAt`; flows through `GET /api/models` and `listRegistryModels()` unchanged. | ||||
| 40 | + | ||||
| 41 | +### Tests (`tests/unit/`) | ||||
| 42 | +`models-search.test.ts` (intent parsing + ranking, 11), `models-badges.test.ts` (quantile badges, lifecycle, parameter sheet/presets, 9), `models-slug.test.ts` (4). `pnpm test` → 125/125. | ||||
| 43 | + | ||||
| 44 | +## API changes | ||||
| 45 | +- `GET /api/models` — **additive**: every model's `metadata.firstSeenAt` (ISO string). No new routes. | ||||
| 46 | +- Presets: reuses `POST /api/presets?kind=model` `{ name, modelKey, systemPrompt, parameters, tools:{builtin} }` from the settings panel. | ||||
| 47 | + | ||||
| 48 | +## Integration notes for other areas | ||||
| 49 | +- **A (chat):** pass `showAuto` (and optionally `draft`) to `ModelSelector` once the composer handles `AUTO_MODEL_KEY`; `trigger` lets the active-model pill open the picker. Router mode is in `localStorage["polyllm:router-mode"]` (JSON string via `useLocalStorage`; export `ROUTER_MODE_KEY` from `model-selector.tsx`). "Compare" actions navigate to `/app/models/compare?m=…`; profile "Arena" links to `/app/arena?models=key` — C should read `?models=` (comma-separated keys). | ||||
| 50 | +- **F (sitemap):** add `/models` and the popular `/compare/<slug>` pages (`popularComparisons()` in `lib/models/public-data.ts`) to `app/sitemap.ts`; consider linking "Models" in the marketing header/footer. | ||||
| 51 | +- **E (custom endpoints):** `lib/models/*` never branches on provider ids except `params.ts` code-execution (OpenAI/Anthropic/Gemini, mirroring the previous UI) — set `metadata.codeExecution` explicitly on custom models to override. | ||||
| 52 | + | ||||
| 53 | +## Heuristics worth knowing | ||||
| 54 | +- CHEAP = blended (input + output $/1M) ≤ 25th percentile of the models passed in; FAST = fast-tier name regex or output price ≤ min($2, 35th percentile); LONG CONTEXT ≥ 400K; CODING = `metadata.coding` or codestral/coder/devstral/codex/"code"; OPEN SOURCE = `metadata.openWeights`/`openSource` or a vendor/name regex (llama, qwen, gemma, gpt-oss, deepseek, kimi…). | ||||
| 55 | +- NEW = `metadata.firstSeenAt` within 30 days **unless** more than half the registry is that recent (fresh registry ⇒ falls back to provider `createdAt`/`releaseDate` only). | ||||
| 56 | +- Lifecycle priority: Retiring (shutdown < 90 d) > Deprecated > Unavailable (no key) > Preview > New > Unverified > Active. | ||||
| 57 | + | ||||
| 58 | +## Coming soon / not done | ||||
| 59 | +- No `<ComingSoon />` placeholders were needed; every control works. | ||||
| 60 | +- `/models/compare?m=` outside the app is not a route (the public URL form is `/compare/[slug]`; the app form is `/app/models/compare?m=`). | ||||
| 61 | +- Public pages show list prices from the last catalog audit; there is no per-user pricing. | ||||
| 62 | + | ||||
| 63 | +## Visual QA checklist (375 / 390 / 393 / 430 / 1440) | ||||
| 64 | +1. Chat header → model pill → sheet opens full height, search input fixed, keyboard does not cover the list; scroll the list, pull down while scrolled (must not dismiss), pull down at top (dismisses). Desktop: ⌘/ opens dialog, input focused, ↑↓↵ pick, Esc closes. | ||||
| 65 | +2. Type "cheap vision", "1M context", "under $1/M", "fastest gemini", "json schema", "open source", "sonet": "Understood" chips appear, results ranked; empty state text on nonsense. | ||||
| 66 | +3. Sections: Favorites / Recent appear only when non-empty; "Show all N" expands; "All models" grouped by provider; badges and status chips do not wrap rows (row stays 56 px). | ||||
| 67 | +4. Star toggles with pop animation; long-press a row (touch) → ActionSheet; "…" on desktop → menu; Set label → accent label shows in row, trigger pill and profile; Remove label works. | ||||
| 68 | +5. AUTO card (with `showAuto`): Segmented modes scroll horizontally on phones, choice persists after reload; "Right now → model · reason" updates with mode. | ||||
| 69 | +6. Profile sheet: facts show "—" for missing release date / cutoff; capability grid, parameter groups, footer buttons on one row at 375 px (labels truncate, not overflow). | ||||
| 70 | +7. Settings panel: phone sheet / desktop right panel; groups collapse; slider + numeric input stay in sync; Reset disabled at 0 settings; Save preset creates a row (check `/app/presets`); Apply preset lists incompatible reasons and disables Apply. | ||||
| 71 | +8. Catalog: no horizontal overflow at 375; filters toggle; sort select on phone; desktop headers sort with arrows; select 2–4 → compare bar above bottom nav → compare page; Deprecated status loads extra models. | ||||
| 72 | +9. `/app/models/compare?m=openai/gpt-5.5,anthropic/claude-sonnet-5`: chips, add via picker (max 4), spec cards stacked on phone, grid on desktop, meters proportional. | ||||
| 73 | +10. Public `/models` and `/compare/gpt-5.5-vs-claude-sonnet-5` unauthenticated: header/footer from the marketing layout, metadata title/description, 404 on `/compare/foo-vs-bar`, CTAs go to `/signup` and `/app/arena?models=…`. | ||||
| 74 | +11. Dark mode and `prefers-reduced-motion` (pop animation disabled by the global rule) on all of the above. | ||||
added
docs/upgrade-notes/C-arena.md
+118 −0
@@ -0,0 +1,118 @@ | |||||
| 1 | +# Workstream C — Arena (2026-09-11) | ||||
| 2 | + | ||||
| 3 | +Mobile-first rebuild of the Arena: adaptive layouts, live metrics, per-criterion votes, Arena Winner, Blind Arena, | ||||
| 4 | +history, personal scoreboard, Markdown/JSON exports and public sharing. All existing backend behaviour is preserved | ||||
| 5 | +(`/api/arena` GET/POST/PATCH, `/api/arena/stream`, `arena_responses.ratings` kept in sync). | ||||
| 6 | + | ||||
| 7 | +## Files | ||||
| 8 | + | ||||
| 9 | +### New | ||||
| 10 | +| Path | Role | | ||||
| 11 | +| --- | --- | | ||||
| 12 | +| `src/lib/arena/scoring.ts` | Pure: criteria (`BUILTIN_CRITERIA`, `customCriterion`, `criterionById`, `ratingKeyFor`), categories (`categoryFromTask`), `computeWinner`, `computeScoreboard`, Blind helpers (`shuffledOrder`, `normalizeOrder`, `blindLabel`) | | ||||
| 13 | +| `src/lib/arena/metrics.ts` | Pure: `liveMetrics()` (estimates from char count while streaming → exact on `done`), `formatDelta` | | ||||
| 14 | +| `src/lib/arena/export.ts` | Pure: `buildArenaMarkdown`, `buildArenaJson`, `buildArenaShareSnapshot`, `generationParameters`, `arenaExportFilename` | | ||||
| 15 | +| `src/app/api/arena/vote/route.ts` | POST / DELETE votes | | ||||
| 16 | +| `src/app/api/arena/scoreboard/route.ts` | GET scoreboard | | ||||
| 17 | +| `src/app/api/arena/[id]/route.ts` | GET one session (responses + votes), DELETE session | | ||||
| 18 | +| `src/app/api/arena/[id]/export/route.ts` | POST export (Markdown / JSON) | | ||||
| 19 | +| `src/app/api/arena/[id]/share/route.ts` | GET status / POST create-refresh / DELETE revoke | | ||||
| 20 | +| `src/app/app/arena/scoreboard/page.tsx` + `src/components/arena/scoreboard-view.tsx` | Personal scoreboard | | ||||
| 21 | +| `src/app/share/arena/[id]/page.tsx` + `src/components/arena/shared-arena-view.tsx` | Public share page (server page + client carousel) | | ||||
| 22 | +| `src/components/arena/metrics-strip.tsx` | `MetricsStrip`, `StatusDot`, `STATUS_META`, `useTick` (shared 500 ms clock) | | ||||
| 23 | +| `src/components/arena/vote-panel.tsx` | `VotePanel` chips + `useCustomCriteria` (localStorage `polyllm:arena-criteria`) | | ||||
| 24 | +| `src/components/arena/winner-card.tsx` | Arena Winner card (`animate-pop`, Δ cost / Δ TTFT / Δ output) | | ||||
| 25 | +| `src/components/arena/comparison-table.tsx` | End-of-run comparison: stacked metric rows < `md`, table ≥ `md` | | ||||
| 26 | +| `src/components/arena/model-tabs.tsx` | Sticky phone tab bar (provider icon / blind letter, status dot, live tok/s, trophy) | | ||||
| 27 | +| `src/components/arena/blind.tsx` | `BlindAvatar`, `Flip`, `useFlip` (two-phase rotateY reveal, no CSS additions) | | ||||
| 28 | +| `src/components/arena/share-sheet.tsx` | `ArenaShareSheet` (`ResponsiveDialog`; privacy warning → link, views, refresh, revoke) | | ||||
| 29 | +| `src/components/arena/download.ts` | `downloadArenaExport()` (blob download, filename from headers) | | ||||
| 30 | +| `tests/unit/arena-scoring.test.ts` | 19 vitest units: winner (majority, tie → TTFT, deltas, dedupe), scoreboard (rates, category/criterion filters), criteria/categories, blind permutation, live metrics, exports/snapshot (no attachment data) | | ||||
| 31 | + | ||||
| 32 | +### Changed | ||||
| 33 | +| Path | Change | | ||||
| 34 | +| --- | --- | | ||||
| 35 | +| `src/components/arena/arena-view.tsx` | Rewritten: header (layout Segmented, sync scroll, Reveal, New, Stop, menu), scrollable results, composer pinned at the bottom with model chips + `ModelConfig` + Blind toggle in `leftSlot`; phone `.snap-row` carousel + sticky `ModelTabs`; votes, winner, comparison table, history, share sheet, deep links | | ||||
| 36 | +| `src/components/arena/arena-column.tsx` | Live `MetricsStrip` under every response (all statuses), `VotePanel` once final, blind identity (letter avatar + `Flip`), per-column Reveal, Winner badge; `STATUS_META`/`StatusDot` re-exported for compatibility | | ||||
| 37 | +| `src/components/arena/arena-history.tsx` | Phone rows (tap = open read-only, long-press / `…` = ActionSheet), desktop table (Prompt · Models · Winner · Cost · Date · actions), `ConfirmDialog` delete, export/share entries | | ||||
| 38 | +| `src/components/arena/model-picker.tsx` | Single scrollable row on phones (`overflow-x-auto`, `md:flex-wrap`), `.tap` targets | | ||||
| 39 | +| `src/components/arena/types.ts` | `ArenaVoteDto`, `ArenaSessionDto.votes`, `sessionBlind/sessionOrder/sessionAttachmentCount/sessionWinner/sessionCost` | | ||||
| 40 | +| `src/lib/arena/service.ts` | `blind` → `settings.blind` + shuffled `settings.blindOrder`; Arena flags stripped before `filterSettings`; sessions include `votes`; `getArenaSession`, `deleteArenaSession`, `castArenaVote` (upsert + legacy ratings mirror), `retractArenaVote`, `getArenaScoreboard`, `exportArenaSession`, `shareArenaSession`, `revokeArenaShare`, `getArenaShare`, `getPublicArenaShare` | | ||||
| 41 | +| `src/lib/chat/schemas.ts` (additive, arena-only) | `arenaRunSchema.blind`, `arenaCriterionSchema`, `arenaCategorySchema`, `arenaVoteSchema`, `arenaRetractVoteSchema` | | ||||
| 42 | + | ||||
| 43 | +No new dependencies, no schema/migration changes (`arena_votes` and `shared_arena_sessions` from `0001_workspace_upgrade` are used as-is). | ||||
| 44 | + | ||||
| 45 | +## API routes | ||||
| 46 | + | ||||
| 47 | +| Method | Path | Body / query | Response | | ||||
| 48 | +| --- | --- | --- | --- | | ||||
| 49 | +| GET | `/api/arena` | — | `{ sessions: (ArenaSession & { responses, votes })[] }` — **now includes `votes`** | | ||||
| 50 | +| POST | `/api/arena` | `{ prompt, systemPrompt?, modelKeys[1..4], settings?, attachmentIds?, blind? }` | `{ session }` (settings carry `blind: true`, `blindOrder: number[]` when blind) | | ||||
| 51 | +| PATCH | `/api/arena` | `{ responseId, ratings }` | unchanged (legacy) | | ||||
| 52 | +| POST | `/api/arena/stream` | `{ sessionId, modelKey }` | SSE, unchanged | | ||||
| 53 | +| POST | `/api/arena/vote` | `{ sessionId, responseId, criterion, category? }` — criterion `best\|accurate\|writing\|coding\|value\|fastest\|custom:<slug>`; category `coding\|research\|writing\|reasoning\|general` (client passes `categoryFromTask(analyzePrompt(prompt).task)`) | `{ votes, ratings: { [responseId]: ratings } }` — upsert on `(session_id, criterion)`, mirrors `ratingKeyFor(criterion)` into `arena_responses.ratings` (exclusive) | | ||||
| 54 | +| DELETE | `/api/arena/vote` | `{ sessionId, criterion }` | `{ votes, ratings }` | | ||||
| 55 | +| GET | `/api/arena/scoreboard` | `?category=&criterion=` (Cost efficiency = `criterion=value`) | `{ rows: ScoreboardRow[], sessions, votes }` — per model: `sessions`, `decided`, `wins`, `votes`, `winRate = wins / decided`, `avgCostUsd`, `avgTtftMs`, `criteria{}`. Session category = voters' category if any, else `analyzePrompt(prompt)` | | ||||
| 56 | +| GET | `/api/arena/:id` | — | `{ session }` (responses + votes, owner only) | | ||||
| 57 | +| DELETE | `/api/arena/:id` | — | `{ ok }` — removes responses (cascade), votes, share links; usage records kept | | ||||
| 58 | +| POST | `/api/arena/:id/export?format=markdown\|json` | — | file (`Content-Disposition`, `X-Filename`) with models, parameters, metrics, votes/ratings, winner, responses | | ||||
| 59 | +| GET | `/api/arena/:id/share` | — | `{ share: { id, createdAt, viewCount } \| null }` | | ||||
| 60 | +| POST | `/api/arena/:id/share` | — | `{ share }` — creates or refreshes the snapshot in `shared_arena_sessions` (no attachment data) | | ||||
| 61 | +| DELETE | `/api/arena/:id/share` | — | `{ ok }` — revokes | | ||||
| 62 | +| page | `/share/arena/:shareId` | — | public, `robots: noindex`, increments `view_count` | | ||||
| 63 | + | ||||
| 64 | +Deep links on `/app/arena`: `?models=a,b` preselects (unknown keys skipped with a toast), `?session=<id>` opens a stored | ||||
| 65 | +session read-only; the URL is cleaned with `router.replace` afterwards. | ||||
| 66 | + | ||||
| 67 | +## Behaviour notes | ||||
| 68 | + | ||||
| 69 | +- **Layout**: desktop 1 → single, 2 → side by side, 3–4 → 2×2 grid by default, `Columns` toggle (Segmented in the header) | ||||
| 70 | + switches to 3/4 columns from `xl`. Phone: one response per screen in a `.snap-row` (`useSnapCarousel`), sticky tab bar | ||||
| 71 | + scrolls the carousel, swipe updates the tab; never more than one column. Composer + model chips are a fixed bottom bar | ||||
| 72 | + (page is `h-full min-h-0 flex-col`; the shell reserves bottom-nav space). | ||||
| 73 | +- **Live metrics**: `liveMetrics()` — while streaming: elapsed/TTFT from timestamps, output tokens from | ||||
| 74 | + `estimateTextTokens(text + reasoning)`, tok/s after 400 ms of generation, cost via `estimateCost(model, promptTokens, outTokens)` | ||||
| 75 | + (`≈` prefix). On `done`: exact server usage/latency/cost. One shared 500 ms clock (`useTick`) while anything streams. | ||||
| 76 | +- **Winner**: most criteria won; tie → lowest TTFT (then latency, then order); needs all responses final + ≥ 1 vote. | ||||
| 77 | + Deltas = winner − mean(other *complete* responses). | ||||
| 78 | +- **Blind Arena**: toggle next to the composer (persists for re-runs). Server shuffles positions (`blindOrder`); the client | ||||
| 79 | + shows Model A/B/C/D with neutral letter avatars, hides provider name/icon and the reasoning badge. Voting **Best answer**, | ||||
| 80 | + the header **Reveal** button or a column's eye icon flips identities (`Flip`, 160 ms, reduced-motion safe). Stored blind | ||||
| 81 | + sessions open revealed; exports/shares reveal names and mark the session as blind. | ||||
| 82 | +- **Custom criteria**: `polyllm:arena-criteria` (`[{id, label}]`, max 12). Ids `custom:<slug>`; legacy rating key `bestCustom_<slug>`. | ||||
| 83 | +- **Scoreboard math**: a session is *decided* when it has ≥ 1 vote matching the filter; `winRate = wins / decided` | ||||
| 84 | + (models that only appear in unvoted sessions are listed as unranked). Cost efficiency = `criterion=value`. | ||||
| 85 | + | ||||
| 86 | +## Coming soon / not done | ||||
| 87 | +- Nothing is labelled "Coming soon" in the Arena UI; every control is wired. | ||||
| 88 | +- Arena votes are not (yet) surfaced in Usage analytics or the Models catalog — the scoreboard API (`GET /api/arena/scoreboard`) | ||||
| 89 | + is ready for the Models workstream if it wants a "your win rate" badge. | ||||
| 90 | +- Share snapshots are frozen: votes cast after sharing need **Refresh snapshot** (documented in the sheet). | ||||
| 91 | + | ||||
| 92 | +## Integration requests (not in my files) | ||||
| 93 | +- `src/lib/ids.ts`: I use `newId("arv")` for vote ids; optionally add `arenaVote: () => newId("arv")` to `ids` for consistency. | ||||
| 94 | +- Chat "Compare with…" (workstream A) can open a stored comparison with `/app/arena?session=<id>` and preselect with `?models=`. | ||||
| 95 | +- Nothing else touches other areas; `Composer`/`ModelSelector`/`ModelConfig` are used with their current props only. | ||||
| 96 | + | ||||
| 97 | +## Validation | ||||
| 98 | +- `pnpm typecheck`: clean (whole project at the time of writing). | ||||
| 99 | +- `pnpm lint`: arena paths clean; remaining project problems are in `models/page.tsx`, `onboarding-flow.tsx`, `chat/*`, `search/*` (other workstreams). | ||||
| 100 | +- `pnpm test`: 124/125 — the single failure is `formatPricePair` in the Models tests (not arena); `tests/unit/arena-scoring.test.ts` 19/19. | ||||
| 101 | +- React Compiler note: keep `useSnapCarousel` destructured (`{ ref, index, scrollTo }`) — the returned object is treated as a ref | ||||
| 102 | + and reading `carousel.index` in render is flagged. Do not store `reset` (a ref-mutating callback) inside the header menu array; | ||||
| 103 | + it is a header button instead. | ||||
| 104 | + | ||||
| 105 | +## Visual QA checklist (integration phase, 375 / 390 / 393 / 430 px + 1440) | ||||
| 106 | +1. `/app/arena` empty state: header 48 px with hamburger, bottom bar shows "Choose models" + presets in one scrollable row, composer above the bottom nav, no horizontal overflow. | ||||
| 107 | +2. Pick 4 models → chips scroll horizontally on phone, wrap on desktop; `4/4` hides "Add model"; ModelConfig + Blind toggle sit in the composer's left slot. | ||||
| 108 | +3. Run: phone shows the sticky tab bar (icon, name, dot, tok/s), one panel per screen, swipe left/right changes the active tab and vice-versa; "Swipe to compare · n / 4" updates. Desktop: 2×2 grid; header Segmented switches to 4 columns at ≥ 1280 px; sync scroll works at ≥ 1024 px. | ||||
| 109 | +4. While streaming: metrics strip shows Elapsed → TTFT, ≈ tokens, tok/s appears after ~0.5 s, ≈ cost ticks; on done values lose the ≈ and match the stored numbers. | ||||
| 110 | +5. Vote: chips under each final response; voting the same criterion on another response moves it; retract by tapping again; "+ Criterion" opens the PromptDialog (sheet on phone), custom chip shows an × (always visible on touch). | ||||
| 111 | +6. After ≥ 1 vote with all responses final: Winner card pops in with deltas, the winning column gets the accent border + Winner badge, tabs show the trophy, Comparison section renders stacked rows on phone / a table on desktop with best-per-column highlighting. | ||||
| 112 | +7. Blind: toggle before running → columns show Model A–D with letter avatars, shuffled order, no provider names; vote "Best answer" → flip reveal; "Reveal" also in the header and per column eye icon. | ||||
| 113 | +8. History: phone rows (tap opens read-only with "stored · date" pill; long-press or … opens the ActionSheet with Open / Run again / Export MD / Export JSON / Share / Delete); desktop table with hover-revealed Run again + … menu; Delete asks for confirmation and removes the active session if open. | ||||
| 114 | +9. Menu (…): Export Markdown / JSON downloads a file; Share… opens the sheet with the privacy warning → Create public link → URL, copy, open, views, Revoke → `/share/arena/:id` returns 404. | ||||
| 115 | +10. `/share/arena/:id`: branded header, prompt bubble, Settings details, phone carousel with sticky tabs under the 56 px header, desktop grid, winner card + comparison, CTA; `noindex`. | ||||
| 116 | +11. `/app/arena/scoreboard`: chip filters scroll on phone; ranking list with rank tile, provider icon, win-rate bar, stats; empty state copy; back arrow to the Arena. | ||||
| 117 | +12. Deep links: `/app/arena?models=openai/gpt-5.5,anthropic/claude-sonnet-5` preselects; `/app/arena?session=arn_…` opens read-only; URL is cleaned. | ||||
| 118 | +13. Keyboard: ⌘↵ runs, Esc stops, ←/→ on the tab bar moves between models, Segmented is keyboard-navigable; all icon buttons have `aria-label`s; `prefers-reduced-motion` disables the flip/pop. | ||||
added
docs/upgrade-notes/D-projects.md
+92 −0
@@ -0,0 +1,92 @@ | |||||
| 1 | +# D — Projects, prompt library, context/file library (2026-09-11) | ||||
| 2 | + | ||||
| 3 | +Workstream D of the PolyLLM 1.0 upgrade. Everything below compiles (`pnpm typecheck` / `pnpm lint` clean for the D | ||||
| 4 | +files; remaining typecheck errors at hand-off time are in A/B/C/E/F files still being edited) and `pnpm test` passes | ||||
| 5 | +(`tests/unit/prompt-variables.test.ts` added, 7 cases). | ||||
| 6 | + | ||||
| 7 | +## Files | ||||
| 8 | + | ||||
| 9 | +### Services (server) | ||||
| 10 | +- `src/lib/projects/service.ts` — `listProjects`, `getProject`, `getProjectDetail`, `createProject`, `updateProject`, `deleteProject` (keeps conversations: `project_id → null`; files cascade; prompts detached), `moveConversations`, `listUnassignedConversations`, `toPublicProject` (`PublicProject`, `ProjectDetail` types). | ||||
| 11 | +- `src/lib/projects/schemas.ts` — zod `projectBodySchema` (POST; PATCH = `.partial()`), `projectActionSchema`. | ||||
| 12 | +- `src/lib/library/service.ts` — `listFiles`, `getFile`, `saveFile` (kinds/limits mirror `/api/attachments`: 10 MB, 2 MB text; PNG/JPEG dimensions; `estimatedTokens` via `estimateAttachmentTokens`; 500 files/user cap), `updateFile` (rename / description / move to project), `deleteFile`, `attachFiles` (copies into `message_attachments` with `messageId`/`conversationId` null — exactly what POST `/api/attachments` produces; the chat service links them on send), `toPublicFile` (`PublicProjectFile`, `PendingAttachment`). | ||||
| 13 | +- `src/lib/prompts/variables.ts` — pure, client-safe: `parseVariables`, `mergeVariables`, `renderTemplate`, `labelFor`, `variablesToAsk`, `VARIABLE_RE` (`{{name}}`, whitespace tolerated, names `[A-Za-z_][\w.-]*`; JSON braces untouched). | ||||
| 14 | +- `src/lib/prompts/service.ts` — `listPrompts` (search over name/description/content/tags, kind/folder/tag/favorite/project filters, returns `folders` + `tags` facets), `getPrompt`, `createPrompt`, `updatePrompt` (variables re-merged with content), `deletePrompt`, `usePrompt` (uses++, `lastUsedAt`, renders), `PROMPT_KINDS`, `toPublicPrompt` (`PublicPrompt`, `PromptKind`, `UsePromptResult`). | ||||
| 15 | +- `src/lib/prompts/schemas.ts` — zod `promptBodySchema`, `promptVariableSchema`, `promptUseSchema`. | ||||
| 16 | +- `src/lib/conversations/service.ts` — **additive**: `projectId` in `ListFilter` (`"none"` supported), `createConversation`, `updateConversation`, and `PublicConversation` (now an explicit interface; `projectId?: string | null` is optional so A's optimistic conversation object stays assignable — A may set `projectId: activeProjectId` there). | ||||
| 17 | +- `src/lib/client/types.ts` — appended re-exports: `PublicProject`, `ProjectDetail`, `PublicProjectFile`, `PendingAttachment`, `PublicPrompt`, `PromptKind`, `UsePromptResult`, `PromptVariable`. | ||||
| 18 | +- Ids: `prj_…` (projects), `pfl_…` (library files) via `newId()`; prompts reuse `ids.prompt()` (`prm_…`, distinct table from `prompt_presets`). | ||||
| 19 | + | ||||
| 20 | +### Routes | ||||
| 21 | +- `src/app/api/projects/route.ts`, `src/app/api/projects/[id]/route.ts` | ||||
| 22 | +- `src/app/api/library/files/route.ts`, `src/app/api/library/files/[id]/route.ts`, `src/app/api/library/files/attach/route.ts` | ||||
| 23 | +- `src/app/api/prompts/route.ts`, `src/app/api/prompts/[id]/route.ts`, `src/app/api/prompts/[id]/use/route.ts` | ||||
| 24 | +- `src/app/api/conversations/route.ts` (`?projectId=`, `projectId` in create body), `src/app/api/conversations/[id]/route.ts` (`projectId` in PATCH). | ||||
| 25 | + | ||||
| 26 | +### UI | ||||
| 27 | +- Pages: `src/app/app/projects/page.tsx`, `src/app/app/projects/[id]/page.tsx`, `src/app/app/library/page.tsx`, `src/app/app/prompts/page.tsx` (replaced). | ||||
| 28 | +- `src/components/projects/`: `page-header.tsx` (`WorkspacePageHeader` 48 px bar with hamburger/back + `WorkspacePageBody`), `project-icon.tsx`, `project-form-sheet.tsx` (create/edit: name, emoji, colour, description), `projects-view.tsx` (grid, Active/Archived, search, card menu, `?new=1`), `project-detail.tsx` (tabs Chats / Files / Prompts / Instructions & models / Notes, stats, `?tab=`), `project-conversations.tsx` (rows, remove, "Add existing chats" picker), `project-setup.tsx` (instructions, preferred models via `ModelSelector multiple`, default settings via `ParametersForm`, sticky save bar), `project-notes.tsx` (markdown write/preview, ⌘S), `project-switcher.tsx` (ActionSheet on phone / dropdown on desktop), `sidebar-section.tsx` (finished: count, active dot + "Active", switcher button, overflow link, skeleton), `row-menu.tsx` (shared ⋯ menu: ActionSheet + long-press on phone, hover dropdown on desktop). | ||||
| 29 | +- `src/components/library/`: `file-library-picker.tsx` (finished: search, scope chips, multi-select ≤ 10, token total, inline upload), `file-list.tsx` (`FileList`, `FileThumb`, `FileKindIcon`, preview sheet, move/describe/delete), `library-view.tsx` (filters by project & kind, drag-and-drop upload on desktop), `upload-button.tsx` (`UploadButton`, `uploadLibraryFiles`, `reportUpload`), `use-in-chat.ts` (files → new chat hand-off), `format.ts`. | ||||
| 30 | +- `src/components/prompts/`: `prompt-library.tsx` (search, kind Segmented, favourites/folders/tags chips, `?new=1`/`?edit=`/`?content=&kind=` deep links, legacy section), `prompt-card.tsx` (card + compact row), `prompt-editor-sheet.tsx` (live variable detection with label/default/options/required, JSON schema for structured, folder datalist, tag chips, default model, project), `prompt-use-sheet.tsx` (fill-in + preview + `insertPromptIntoChat`), `legacy-presets.tsx` (lists `prompt_presets`, Import / Import all / legacy Use / Delete), `prompt-kind.tsx`, `insert.ts` (event contract, see below). | ||||
| 31 | +- `tests/unit/prompt-variables.test.ts`. | ||||
| 32 | + | ||||
| 33 | +## API | ||||
| 34 | + | ||||
| 35 | +All routes: `withUser` (401 otherwise), errors as `{ error: { code, message } }`. | ||||
| 36 | + | ||||
| 37 | +| Method | Path | Body / query → response | | ||||
| 38 | +| --- | --- | --- | | ||||
| 39 | +| GET | `/api/projects?archived=1` | → `{ projects: PublicProject[] }` (non-archived by default; each has `conversationCount`, `fileCount`, `promptCount`) | | ||||
| 40 | +| POST | `/api/projects` | `{ name, description?, icon?, color?, instructions?, preferredModelKeys?, defaultSettings?, notes?, archived?, sortOrder? }` → 201 `{ project }` | | ||||
| 41 | +| GET | `/api/projects/:id` | → `{ project, conversations: PublicConversation[], files: PublicProjectFile[], prompts: PublicPrompt[], stats: { conversations, messages, files, prompts, totalCostUsd, totalInputTokens, totalOutputTokens, fileBytes, fileTokens, lastActivityAt } }`; `?unassigned=1` → `{ conversations }` outside any project | | ||||
| 42 | +| PATCH | `/api/projects/:id` | any subset of the create body (`defaultSettings` validated by `generationSettingsSchema`) → `{ project }` | | ||||
| 43 | +| POST | `/api/projects/:id` | `{ action: "add-conversations" \| "remove-conversations", conversationIds }` → `{ moved }` | | ||||
| 44 | +| DELETE | `/api/projects/:id` | conversations kept (`project_id` null), files cascade, prompts detached → `{ ok }` | | ||||
| 45 | +| GET | `/api/library/files?projectId=<id\|none>&q=&kind=&limit=` | → `{ files: PublicProjectFile[] }` (metadata only) | | ||||
| 46 | +| POST | `/api/library/files` | multipart `file`, `projectId?`, `description?` → 201 `{ file }` (415 unsupported, 413 > 10 MB / text > 2 MB, 409 library full) | | ||||
| 47 | +| DELETE | `/api/library/files?id=` | → `{ ok }` | | ||||
| 48 | +| GET | `/api/library/files/:id` | streams the payload (`inline`; `?download=1` → attachment; `?meta=1` → `{ file }`) | | ||||
| 49 | +| PATCH | `/api/library/files/:id` | `{ name?, description?, projectId? }` (`projectId: null` = global) → `{ file }` | | ||||
| 50 | +| DELETE | `/api/library/files/:id` | → `{ ok }` | | ||||
| 51 | +| POST | `/api/library/files/attach` | `{ fileIds: string[] (1–10) }` → 201 `{ attachments: [{ id, kind, name, mimeType, sizeBytes, width, height }] }` — pending `message_attachments` rows; pass the ids in `message.attachmentIds` | | ||||
| 52 | +| GET | `/api/prompts?q=&kind=&folder=<name\|none>&tag=&favorite=1&projectId=<id\|none>&limit=` | → `{ prompts, folders: string[], tags: string[], total }` (command palette: `?q=`) | | ||||
| 53 | +| POST | `/api/prompts` | `{ kind?, name, content, description?, variables?, schema?, folder?, tags?, favorite?, defaultModelKey?, projectId? }` → 201 `{ prompt }` (variables auto-merged with `{{…}}` in content) | | ||||
| 54 | +| GET / PATCH / DELETE | `/api/prompts/:id` | → `{ prompt }` / `{ prompt }` / `{ ok }` | | ||||
| 55 | +| POST | `/api/prompts/:id/use` | `{ variables? }` → `{ prompt, kind, rendered, text, systemPrompt, schema, defaultModelKey, missing, defaulted }` (uses++, `lastUsedAt`) | | ||||
| 56 | +| GET | `/api/conversations?projectId=<id\|none>` | additive filter | | ||||
| 57 | +| POST / PATCH | `/api/conversations`, `/api/conversations/:id` | `projectId: string \| null` accepted | | ||||
| 58 | + | ||||
| 59 | +## Prompt → composer contract (`src/components/prompts/insert.ts`) — for workstream A | ||||
| 60 | + | ||||
| 61 | +1. Library calls `POST /api/prompts/:id/use { variables }`, then `dispatchPromptInsert(detail)`: | ||||
| 62 | + - `window.dispatchEvent(new CustomEvent("polyllm:insert-prompt", { detail }))` (`PROMPT_INSERT_EVENT`), and | ||||
| 63 | + - `sessionStorage["polyllm:pending-prompt-insert"]` = same JSON (`PENDING_PROMPT_KEY`), for a chat view that mounts later; | ||||
| 64 | + - then `router.push("/app/chat?promptInsert=<id>&vars=<base64url JSON>")` (`promptChatHref`). | ||||
| 65 | +2. `PromptInsertDetail = { promptId, name, kind, text, systemPrompt, schema, defaultModelKey, variables, at }`. | ||||
| 66 | +3. Chat view should: | ||||
| 67 | + - `usePromptInsert(handler)` for live inserts while mounted; | ||||
| 68 | + - on mount of a new chat: `consumePendingPromptInsert()` (fast path, returns null if absent/older than 5 min); if only URL params are present (shared link, reload): `POST /api/prompts/<promptInsert>/use` with `decodeVars(vars)` and apply the result the same way; | ||||
| 69 | + - apply: `kind === "system"` → set the conversation system prompt to `systemPrompt`; `user`/`template` → insert `text` into the draft; `structured` → insert `text` and, if `schema`, request `responseFormat: { type: "json_schema", schema }`; `defaultModelKey` may switch the model when usable. | ||||
| 70 | + - Legacy `?prompt=<prompt_presets id>` keeps its current behaviour (unchanged); optionally fall back to `GET /api/prompts/:id` when the id is not a legacy preset. | ||||
| 71 | +4. "Save as prompt" from a message: navigate to `/app/prompts?new=1&kind=<system|user>&content=<encoded>` — the library opens the editor prefilled. | ||||
| 72 | + | ||||
| 73 | +## Library files → composer contract (`src/components/library/use-in-chat.ts`) — for workstream A | ||||
| 74 | + | ||||
| 75 | +`prepareFilesForNewChat(fileIds)` → POST attach, stashes `{ at, attachments }` in `sessionStorage["polyllm:pending-attachments"]` (`PENDING_ATTACHMENTS_KEY`) and returns `/app/chat?attachments=<id,id>` (plus `&project=<id>` when launched from a project). Chat view: on mount of a new chat read `?attachments=` and `consumePendingAttachments()` to seed composer chips; the ids go straight into `message.attachmentIds`. `FileLibraryPicker` (already imported by the composer) keeps its `{ open, onOpenChange, onPick(PickedAttachment[]), projectId? }` API. | ||||
| 76 | + | ||||
| 77 | +Project context for chat (already planned in A): with `activeProjectId` set, fetch `/api/projects/:id` → `project.instructions` (system prompt), `project.preferredModelKeys[0]` (default model), `project.defaultSettings`; create conversations with `projectId: activeProjectId` (POST `/api/conversations` accepts it; the chat service path that auto-creates a conversation should also set `projectId` — TODO(integration: chat) one field in `lib/chat/service.ts`). | ||||
| 78 | + | ||||
| 79 | +## Coming soon / not done | ||||
| 80 | +- No drag-to-reorder of preferred models (there is a "Make default" action instead) and no project `sortOrder` UI (API supports it). | ||||
| 81 | +- Command palette entries (Open project, Search prompts) are G's; `GET /api/prompts?q=` and `GET /api/projects` are ready. | ||||
| 82 | +- Text preview in the library caps at 40 KB; PDFs open in a new tab (no inline viewer). | ||||
| 83 | +- Legacy presets can be imported/used/deleted but not edited on the new page (import, then edit in the library). | ||||
| 84 | + | ||||
| 85 | +## QA checklist (visual, integration phase) | ||||
| 86 | +- 375/390/430 px: `/app/projects` header 48 px with hamburger; cards stack 1-col; long-press a card → ActionSheet; "+" in sidebar opens the create sheet as a bottom sheet (emoji grid, colour radios ≥ 40 px). | ||||
| 87 | +- `/app/projects/:id`: back chevron; Segmented tabs scroll horizontally without clipping; Chats tab rows 56 px, long-press → Remove; "Add existing" sheet full-height with search; Files tab upload (button) + thumbnails; Setup tab: `ModelSelector` opens with multi-select, list with Default badge, sticky "Save setup" bar appears only when dirty; Notes: Write/Preview segmented on phone, side-by-side ≥ lg. | ||||
| 88 | +- Sidebar/drawer: active project shows dot + "Active"; ⇄ opens ActionSheet (phone) / dropdown (desktop); "+N more…" when > 8. | ||||
| 89 | +- `/app/library`: chips row scrolls; kind chips appear when > 3 files; row ⋯ → Preview (image/text sheet), Download, Move, Description (PromptDialog), Delete (ConfirmDialog); desktop drag-and-drop overlay; upload errors toast per file (unsupported type, > 10 MB). | ||||
| 90 | +- Composer "From library": picker shows scope chips when a project is active, ≤ 10 selection, token total in footer, inline Upload; attached chips appear in composer (A). | ||||
| 91 | +- `/app/prompts`: editor sheet full-height on phone; typing `{{x}}` adds a variable row live; structured kind shows schema textarea with JSON validation; Use on a prompt with variables → fill-in sheet with preview → lands on `/app/chat?promptInsert=…` with the text inserted (A); Use without variables inserts directly; legacy presets section lists old prompts, Import creates a system prompt in folder "Imported presets". | ||||
| 92 | +- No `window.prompt/confirm` anywhere; every icon button has an aria-label; all new inputs are ≥ 15 px on phones. | ||||
added
docs/upgrade-notes/E-usage-providers.md
+165 −0
@@ -0,0 +1,165 @@ | |||||
| 1 | +# Workstream E — Usage analytics, provider management, custom endpoints | ||||
| 2 | + | ||||
| 3 | +Status: complete for this phase. `pnpm typecheck`, `pnpm lint` (0 errors) and `pnpm test` (159 tests, 28 new) are green. | ||||
| 4 | +No dev server was started; no commit was made. One additive migration was generated **and applied locally** | ||||
| 5 | +(`drizzle/0002_endpoints.sql`) — it must run on prod via the mld hook (`pnpm db:migrate`). | ||||
| 6 | + | ||||
| 7 | +## 1. Files | ||||
| 8 | + | ||||
| 9 | +### Created | ||||
| 10 | +| Area | File | | ||||
| 11 | +| --- | --- | | ||||
| 12 | +| Usage API | `src/lib/usage/time.ts` (tz-aware ranges/buckets, pure), `src/lib/usage/savings.ts` (savings math, pure), `src/app/api/usage/export.csv/route.ts` | | ||||
| 13 | +| Usage UI | `src/components/usage/usage-dashboard.tsx`, `lazy-charts.tsx`, `kpi-tiles.tsx`, `usage-filters.tsx`, `insight-cards.tsx`, `recent-activity.tsx`, `types.ts` | | ||||
| 14 | +| Providers | `src/components/providers/provider-row.tsx`, `src/app/api/providers/test/route.ts` | | ||||
| 15 | +| Custom endpoints | `src/lib/ai/providers/custom/index.ts`, `src/lib/endpoints/service.ts`, `src/lib/endpoints/ssrf.ts`, `src/app/api/endpoints/route.ts`, `src/app/api/endpoints/[id]/route.ts`, `src/app/api/endpoints/[id]/test/route.ts`, `src/app/api/endpoints/[id]/sync/route.ts`, `src/components/endpoints/endpoint-sheet.tsx`, `endpoint-row.tsx`, `presets.ts`, `src/app/app/settings/endpoints/page.tsx` | | ||||
| 16 | +| Settings | `src/components/settings/section.tsx` (`SettingsSection`, `SettingsGroup`, `SettingsRow`), `src/components/settings/sections.ts` (nav) | | ||||
| 17 | +| Schema | `drizzle/0002_endpoints.sql` (+ `drizzle/meta/0002_snapshot.json`, journal entry) | | ||||
| 18 | +| Tests | `tests/unit/usage-savings.test.ts`, `usage-time.test.ts`, `endpoints-ssrf.test.ts`, `custom-endpoints.test.ts` | | ||||
| 19 | + | ||||
| 20 | +### Changed | ||||
| 21 | +| File | Change | | ||||
| 22 | +| --- | --- | | ||||
| 23 | +| `src/lib/ai/core/types.ts` | `"custom"` appended to `PROVIDER_IDS`; new `KEYED_PROVIDER_IDS` (everything except custom). `parseModelKey` unchanged (splits on first `/` → provider `custom`). | | ||||
| 24 | +| `src/lib/ai/providers/index.ts` | `ADAPTERS.custom = customPlaceholderAdapter` (fails loudly, never calls a server); `PROVIDER_META.custom`. | | ||||
| 25 | +| `src/lib/ai/providers/env-keys.ts` | `OWNER_KEY_ENV.custom = ""`; `ownerKey()` returns `undefined` for it. | | ||||
| 26 | +| `src/lib/ai/registry/catalog.ts` | `CATALOGS.custom = new Map()`. | | ||||
| 27 | +| `src/lib/client/providers.ts` | `PROVIDERS.custom` (color `--p-custom`, links to Settings → Endpoints). `PROVIDER_ORDER` unchanged (keyed providers only). | | ||||
| 28 | +| `src/components/brand/provider-icon.tsx` | `case "custom"` glyph (plug). | | ||||
| 29 | +| `src/components/marketing/mock-data.ts` | one-line `custom` entry to keep `Record<ProviderId,string>` exhaustive (F's file — exhaustiveness only). | | ||||
| 30 | +| `scripts/provider-matrix.ts` | `TEST_MODELS.custom` placeholder (skipped: no owner key). | | ||||
| 31 | +| `src/db/schema-workspace.ts` | `custom_endpoints.discovered_models jsonb` + `discovered_at` (discovery snapshot so `/api/models` never calls the endpoint). | | ||||
| 32 | +| `src/app/api/models/route.ts` | additive: appends `listCustomModels(userId)`; pushes `"custom"` into `connectedProviders` when ≥ 1 endpoint is `valid`. | | ||||
| 33 | +| `src/lib/usage/service.ts` | rewritten (see API). `UsageRange` kept as a deprecated alias. | | ||||
| 34 | +| `src/app/api/usage/route.ts` | uses `usageQueryFromUrl`. | | ||||
| 35 | +| `src/components/usage/charts.tsx` | dataviz pass (≤ 24 px bars, 4 px data-end radius, 2 px surface gaps, 10 % area wash, hairline grid, tooltips everywhere), new `LatencyByModel` (two small multiples — never a dual axis), `Sparkline`, `ChartEmpty`; labels parsed from wall-clock bucket keys. | | ||||
| 36 | +| `src/app/app/usage/page.tsx` | thin server page → `<UsageDashboard />` in `Suspense` (uses `useSearchParams`). | | ||||
| 37 | +| `src/components/providers/add-key-dialog.tsx` | `ResponsiveDialog` (sheet on phones), paste button (clipboard API when available), show/hide, whitespace check, latency in the toast. Public API (`AddKeyDialog`, `useAddKeyDialog`) unchanged. | | ||||
| 38 | +| `src/app/app/settings/providers/page.tsx` | redesign (rows, statuses, Test connection with latency, More menu / ActionSheet, link card to Endpoints). | | ||||
| 39 | +| `src/app/app/settings/layout.tsx`, `page.tsx` | phone = section list + 48 px back bar per section; desktop = side nav; "Endpoints" added. | | ||||
| 40 | +| `src/app/app/settings/{account,security,appearance,data}/page.tsx` | `SettingsSection` header, 44 px targets / 16 px inputs on phones, audit log → stacked rows below `md`, session actions full-width on phones, CSV usage export link on Data. | | ||||
| 41 | +| `src/lib/client/types.ts` | append-only re-exports (`PublicEndpoint`, `EndpointInput`, `ManualModel`, `UsageSummary`, `SavingsOpportunity`, …). | | ||||
| 42 | +| `.env.example` | `ALLOW_PRIVATE_ENDPOINTS=0` documented. | | ||||
| 43 | + | ||||
| 44 | +## 2. API routes | ||||
| 45 | + | ||||
| 46 | +### `GET /api/usage` | ||||
| 47 | +Query: `range=today|7d|30d|90d|custom|all` (default 30d) · `from`,`to` = `YYYY-MM-DD` (custom, inclusive, clamped to today and 366 days; ≤ 2 days → hourly) · `tz` = IANA zone (validated, default UTC; the client sends `Intl.DateTimeFormat().resolvedOptions().timeZone`) · `provider` · `modelKey` · `projectId` (join `usage_records.conversation_id → conversations.project_id`, scoped to the user). | ||||
| 48 | + | ||||
| 49 | +Response `UsageSummary` (`src/lib/usage/service.ts`): | ||||
| 50 | +```ts | ||||
| 51 | +{ | ||||
| 52 | + range: { key, from: string|null, to: string, bucket: "hour"|"day", tz, days }, | ||||
| 53 | + filters: { provider, modelKey, projectId }, | ||||
| 54 | + kpis: { requests, failures, stopped, errorRate, inputTokens, outputTokens, cachedTokens, reasoningTokens, totalTokens, | ||||
| 55 | + costUsd, unpricedRequests, avgLatencyMs, avgTtftMs|null, avgTokensPerSec|null, avgContextTokens|null }, | ||||
| 56 | + series: { bucket: "YYYY-MM-DD" | "YYYY-MM-DDTHH:00", requests, failures, inputTokens, outputTokens, costUsd }[], // gap-filled in tz | ||||
| 57 | + byProvider: { provider, requests, failures, inputTokens, outputTokens, costUsd }[], | ||||
| 58 | + byModel: { modelKey, provider, requests, failures, inputTokens, outputTokens, cachedTokens, reasoningTokens, costUsd, | ||||
| 59 | + avgLatencyMs, avgTtftMs|null, tokensPerSec|null, avgContextTokens|null }[], | ||||
| 60 | + projection: { dailyAverageCost, estimatedMonthlyCost /* daily avg × 30 */, observedDays, | ||||
| 61 | + previous: { costUsd, requests } | null, costTrendPct|null, requestsTrendPct|null }, | ||||
| 62 | + savings: SavingsOpportunity[], // top 3, see below | ||||
| 63 | + recent: RecentRecord[60] (+ conversationTitle, projectId via left join), | ||||
| 64 | + facets: { providers[], models: {modelKey, provider, requests}[24], projects: {id,name,icon,color}[] }, | ||||
| 65 | + generatedAt | ||||
| 66 | +} | ||||
| 67 | +``` | ||||
| 68 | +tok/s = `outputTokens / max(latency − ttft, 1 ms)` on successful streaming rows. Avg context = avg input tokens of successful requests. | ||||
| 69 | + | ||||
| 70 | +**Savings** (`computeSavings`, pure, tested): for every model that cost money, candidates = same provider, not deprecated, priced, covering every capability the source has among {vision, tools, structuredOutput, reasoning, files}, context ≥ 1.1 × avg prompt; same `family` preferred. The real token volume (input, cached at cached rate, output) is re-priced with `estimateCost`; the **most capable sibling that still saves ≥ 40 %** wins (Opus → Sonnet, not Haiku). Min $0.01. Each row carries `headline` ("Switching eligible tasks from Claude Opus 4 to Claude Sonnet 4 could have saved ~$17.30") and a `rationale`. | ||||
| 71 | + | ||||
| 72 | +### `GET /api/usage/export.csv` — same query params → `text/csv` attachment (`polyllm-usage-<from>_<to>.csv`, ≤ 50 000 rows, formula-injection-safe quoting, `X-Row-Count` header). | ||||
| 73 | + | ||||
| 74 | +### `POST /api/providers/test` `{ provider }` → `{ ok, latencyMs, modelsAvailable|null, error?, errorCode?, connections }` | ||||
| 75 | +Validates the **stored encrypted key** with `validateApiKey` (never returns the key), refreshes the model list on success. Rate limit `keyValidate`. Rejects `custom`. | ||||
| 76 | + | ||||
| 77 | +### Endpoints (all user-scoped, key material never returned; `PublicEndpoint` carries `keyHint`, `hasKey`, `headerNames` only) | ||||
| 78 | +| Method | Path | Body → Response | | ||||
| 79 | +| --- | --- | --- | | ||||
| 80 | +| GET | `/api/endpoints` | → `{ endpoints: PublicEndpoint[], allowPrivate }` | | ||||
| 81 | +| POST | `/api/endpoints` | `{ name, baseUrl, apiKey?, headers?: Record<string,string>, modelsPath? ("" = manual only), manualModels?: ManualModel[], validate?: boolean=true }` → 201 `{ endpoint, test?: { ok, latencyMs, modelsAvailable, error? } }` | | ||||
| 82 | +| GET | `/api/endpoints/[id]` | → `{ endpoint }` | | ||||
| 83 | +| PATCH | `/api/endpoints/[id]` | partial input; `apiKey: null` clears the key, `headers: null` clears headers; changing URL/key/headers/path resets status to `unverified` → `{ endpoint }` | | ||||
| 84 | +| DELETE | `/api/endpoints/[id]` | → `{ ok: true }` | | ||||
| 85 | +| POST | `/api/endpoints/[id]/test` | → `{ ok, latencyMs, modelsAvailable, error?, errorCode?, endpoint }` | | ||||
| 86 | +| POST | `/api/endpoints/[id]/sync` | → `{ ok, models: PolyModel[], latencyMs, error?, endpoint }` (discovery snapshot persisted) | | ||||
| 87 | + | ||||
| 88 | +`ManualModel = { id, displayName?, contextTokens?, vision?, tools?, reasoning? }` — also acts as capability override for a discovered id. | ||||
| 89 | +Probe = `GET {baseUrl}{modelsPath}` (accepts `{data:[{id}]}`, bare arrays, Ollama `{models:[{name}]}`); with discovery off and manual models present → a 1-token chat completion against the first manual model. | ||||
| 90 | + | ||||
| 91 | +Encryption: `encryptSecret(value, { userId, provider: "custom:<endpointId>" })` → AAD `"<userId>|custom:<endpointId>"` for both the key and the headers JSON. Audit actions: `endpoint.created|updated|deleted`. | ||||
| 92 | + | ||||
| 93 | +SSRF guard (`src/lib/endpoints/ssrf.ts`): http/https only, no credentials in URL, blocks localhost/*.local/*.internal/single-label hosts, IPv4 private/loopback/link-local/CGNAT/test/multicast, IPv6 ::1/ULA/link-local/mapped-v4/NAT64, plus DNS resolution of the hostname at create/update/probe. Bypass only with `ALLOW_PRIVATE_ENDPOINTS=1`. DNS rebinding after the check is out of scope. | ||||
| 94 | + | ||||
| 95 | +## 3. Exact integration hook for chat and Arena (integrator wires — A's and C's files were not edited) | ||||
| 96 | + | ||||
| 97 | +`src/lib/endpoints/service.ts` exports | ||||
| 98 | +```ts | ||||
| 99 | +resolveCustomEndpoint(userId, modelKey): Promise<{ adapter: AIProviderAdapter; apiKey: string; model: PolyModel; endpoint: PublicEndpoint } | null> | ||||
| 100 | +isCustomModelKey(key): boolean | ||||
| 101 | +``` | ||||
| 102 | +Custom keys look like `custom/cep_x:llama3.1:8b` → `getModel()` returns `null` for them (they are not in the registry table) and `getDecryptedKey(userId, "custom")` returns `null`. Wire both services like this: | ||||
| 103 | + | ||||
| 104 | +**`src/lib/chat/service.ts` → `prepareTurn`** — replace | ||||
| 105 | +```ts | ||||
| 106 | +const model = await getModel(input.modelKey); | ||||
| 107 | +if (!model) throw new ApiError(404, "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND"); | ||||
| 108 | +const apiKey = await getDecryptedKey(ctx.userId, model.provider); | ||||
| 109 | +if (!apiKey) throw new ApiError(400, `No API key configured for ${model.provider}. …`, "NO_PROVIDER_KEY", { provider: model.provider }); | ||||
| 110 | +``` | ||||
| 111 | +with | ||||
| 112 | +```ts | ||||
| 113 | +const custom = isCustomModelKey(input.modelKey) ? await resolveCustomEndpoint(ctx.userId, input.modelKey) : null; | ||||
| 114 | +const model = custom?.model ?? (await getModel(input.modelKey)); | ||||
| 115 | +if (!model) throw new ApiError(404, isCustomModelKey(input.modelKey) ? "This custom endpoint no longer exists. Check Settings → Endpoints." : "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND"); | ||||
| 116 | +const apiKey = custom?.apiKey ?? (await getDecryptedKey(ctx.userId, model.provider)); | ||||
| 117 | +if (!apiKey) throw new ApiError(400, `No API key configured for ${model.provider}. Add one in Settings → Providers.`, "NO_PROVIDER_KEY", { provider: model.provider }); | ||||
| 118 | +``` | ||||
| 119 | +add `adapter?: AIProviderAdapter` to `PreparedTurn`, return `adapter: custom?.adapter` from every `return { conversation, model, apiKey, … }`, and in **`runTurn`** replace `const adapter = getAdapter(turn.model.provider);` with `const adapter = turn.adapter ?? getAdapter(turn.model.provider);`. Also skip `recordProviderOutcome` when `turn.model.provider === "custom"` (there is no `provider_connections` row; it is a no-op update anyway) and keep `touchRecent(userId, model.key)` as is (works with any key). | ||||
| 120 | + | ||||
| 121 | +**`src/lib/arena/service.ts`** — `createArenaSession`: for each key, `const custom = isCustomModelKey(key) ? await resolveCustomEndpoint(userId, key) : null; const m = custom?.model ?? await getModel(key); … if (!custom && !(await getDecryptedKey(userId, m.provider))) missing.push(m.provider);`. `runArenaModel`: same three-line substitution as `prepareTurn`, then `const adapter = custom?.adapter ?? getAdapter(model.provider);`. | ||||
| 122 | + | ||||
| 123 | +`apiKey` for key-less endpoints is the sentinel `NO_KEY_SENTINEL` ("polyllm-no-key") — the OpenAI SDK needs a non-empty bearer; Ollama & co. ignore it. `model.pricing` is `null` → `estimateCost().known === false` → `costUsd` stays `null` in `usage_records` (the dashboard counts them as "unpriced"). `usage_records.provider` will be `"custom"` (valid `ProviderId`). | ||||
| 124 | + | ||||
| 125 | +Model picker (B): `/api/models` already returns custom models with `provider: "custom"`, `family` = endpoint name, `metadata.endpointId/endpointName/endpointStatus`; `connectedProviders` includes `"custom"` when at least one endpoint is valid. If B's category chips iterate `PROVIDER_ORDER`, custom models only appear in "All"/search — add a "Custom" chip keyed on `provider === "custom"` if desired. | ||||
| 126 | + | ||||
| 127 | +## 4. Coming soon / known limits (nothing fake shipped) | ||||
| 128 | +- Custom endpoints have **no pricing** (never invented) — their requests show `—` cost and are counted in `kpis.unpricedRequests`. A per-endpoint price sheet is a candidate follow-up. | ||||
| 129 | +- Discovered custom models start **text-only**; users unlock vision/tools/reasoning per id through the manual-models editor. `structuredOutput`/`files` are always off for custom endpoints (server support varies too much to assume). | ||||
| 130 | +- Stored **header values are never shown**; editing any header value re-saves all headers (UI explains this). | ||||
| 131 | +- SSRF: DNS rebinding after the check and redirects to private hosts are not followed (`redirect: "manual"` on discovery; the OpenAI SDK follows redirects for chat — acceptable risk noted). | ||||
| 132 | +- Usage "all time" projection uses the first recorded request as the start of the observation window. | ||||
| 133 | +- No `Coming soon` badges were needed. | ||||
| 134 | + | ||||
| 135 | +## 5. QA checklist (integration phase, single dev server) | ||||
| 136 | +Usage (`/app/usage`) at 375 / 390 / 430 / 1440: | ||||
| 137 | +- [ ] 48 px bar with hamburger, refresh, CSV icon on phones; `PageHeader` + buttons on desktop. | ||||
| 138 | +- [ ] Segmented Today/7d/30d/90d/Custom scrolls horizontally without page overflow; Custom opens a bottom sheet (dialog on desktop) with two `type=date` fields (16 px), presets, validation (end before start), Apply. | ||||
| 139 | +- [ ] Provider/model/project chips filter everything; "Filtered by …" pills clear individually; URL query updates (deep link reload keeps state). | ||||
| 140 | +- [ ] KPI tiles 2-col on phones, 4-col desktop; deltas coloured by direction (cost up = red). | ||||
| 141 | +- [ ] Charts lazy-load with skeletons; no default recharts colours; hour labels for Today, day labels otherwise, in the browser's zone; tooltips on every plot; stacked segments show 2 px surface gaps; failure segment only when failures exist. | ||||
| 142 | +- [ ] Cost by provider donut folds > 6 providers into "Other"; list shows provider icons + values. | ||||
| 143 | +- [ ] Projection card and Savings card (or the honest "nothing to save" state); "Why this sibling?" expands; catalog link works. | ||||
| 144 | +- [ ] Latency by model = two side-by-side small multiples (TTFT ascending, tok/s descending). | ||||
| 145 | +- [ ] Recent requests: rows on phones (tap → conversation), table from `md`; export CSV downloads with the current filters. | ||||
| 146 | +- [ ] Empty states: no data (with "Show all time"), filters with no match ("Clear filters"), API error with Retry. Refetch keeps the previous render at 70 % opacity (no skeleton flash). | ||||
| 147 | + | ||||
| 148 | +Providers (`/app/settings/providers`): | ||||
| 149 | +- [ ] Rows sorted connected → failed → not connected; statuses Connected / Validation failed / Not validated yet / Not connected. | ||||
| 150 | +- [ ] Add key opens a bottom sheet on phones (dialog on desktop) with Paste (only where `clipboard.readText` exists), show/hide, docs link; success toast shows models + latency. | ||||
| 151 | +- [ ] Test connection shows latency inline (green/red) and toasts; failure shows the error under "Last error". | ||||
| 152 | +- [ ] More → Replace / Open console / Remove (ActionSheet on phones, dropdown on desktop); Remove uses ConfirmDialog. | ||||
| 153 | +- [ ] "Custom endpoints" card links to `/app/settings/endpoints` with counts. | ||||
| 154 | + | ||||
| 155 | +Endpoints (`/app/settings/endpoints`): | ||||
| 156 | +- [ ] Empty state with preset chips; Add opens a full-height sheet on phones. | ||||
| 157 | +- [ ] Presets fill URL/path; the private-address warning appears for localhost/LAN URLs and explains tunnel vs `ALLOW_PRIVATE_ENDPOINTS=1` (info tone when the server allows private). | ||||
| 158 | +- [ ] Headers rows add/remove; manual models rows with vision/tools/reasoning switches; discovery toggle hides/shows models path. | ||||
| 159 | +- [ ] "Save & test connection" → latency + discovered model chips; failure keeps the sheet open with the error; "Save without testing" → status "Not tested". | ||||
| 160 | +- [ ] Row actions: Test, More → Edit / Refresh model list / Remove. Models appear in the picker as provider "Custom" (family = endpoint name) after `refreshModels`. | ||||
| 161 | +- [ ] Server rejects `http://localhost:11434/v1` with 400 `PRIVATE_HOST` when `ALLOW_PRIVATE_ENDPOINTS` is unset; accepts it when set to 1. | ||||
| 162 | + | ||||
| 163 | +Settings shell: | ||||
| 164 | +- [ ] Phone `/app/settings` shows the six-section list (≥ 60 px rows); each section has a back chevron bar; desktop `/app/settings` redirects to Account and shows the side nav with "Endpoints". | ||||
| 165 | +- [ ] Account: audit log as stacked rows on phones, table on desktop; inputs 44 px / 16 px on phones. Security: session buttons full-width on phones. Appearance: switches have 44 px hit areas. Data: JSON export + usage CSV link. | ||||
added
docs/upgrade-notes/F-marketing.md
+146 −0
@@ -0,0 +1,146 @@ | |||||
| 1 | +# F — Marketing site, onboarding, auth pages, PWA polish (2026-09-11) | ||||
| 2 | + | ||||
| 3 | +Workstream F of `docs/UPGRADE-PLAN.md`. Everything below compiles (`pnpm typecheck` green, `eslint` clean on every | ||||
| 4 | +file listed here, `pnpm test` 125/125). No dev server was run; visual QA belongs to the integration phase. | ||||
| 5 | + | ||||
| 6 | +## Files | ||||
| 7 | + | ||||
| 8 | +### Created | ||||
| 9 | +| File | Purpose | | ||||
| 10 | +| --- | --- | | ||||
| 11 | +| `src/app/api/public/models/route.ts` | Public, unauthenticated `GET /api/public/models` (see API below). | | ||||
| 12 | +| `src/lib/marketing/public-models.ts` | Pure curation (`curatePublicModels`), `formatContext`, `formatPricePair`, `PublicModelSummary` type. | | ||||
| 13 | +| `tests/unit/public-models.test.ts` | Vitest: filtering, ordering (new → flagship, provider round-robin), cap, field shape, formatters. | | ||||
| 14 | +| `src/components/marketing/fake-stream.ts` | Shared scripted-streaming engine (`useFakeStreams`, `fastestIndex`, `approxTokens`). rAF-driven, instant under reduced motion. | | ||||
| 15 | +| `src/components/marketing/frames.tsx` | `PhoneFrame` (390×844, status bar, home indicator) and `DesktopFrame` (window chrome with URL bar). | | ||||
| 16 | +| `src/components/marketing/product-mock.tsx` | Faithful mocks of the real app: `SidebarMock`, `ModelPill`, `ContextIndicator`, `MessageMeta`, `ComposerMock`, `MobileComposerMock` (`[+] Ask anything… [mic] [send]`), `BottomNavMock` (reads the real `MOBILE_TABS`), `MobileHeaderMock`, `DesktopChatMock`/`MobileChatMock`, `DesktopArenaMock`/`MobileArenaMock` (`.snap-row`, sticky model tabs, dots), `ArenaMiniCard`, `DesktopModelsMock`/`MobileModelsMock`, `DesktopUsageMock`/`MobileUsageMock`, `ScoreboardMock`, `MockMarkdown` (bold, code, paragraphs, pipe tables). | | ||||
| 17 | +| `src/components/marketing/hero-visual.tsx` | Animated hero: desktop window (sidebar, model pill, streaming answer + caret, metadata line, mini Arena card that fades in) / phone frame with the real mobile layout below `md`. One shared stream. | | ||||
| 18 | +| `src/components/marketing/model-strip.tsx` | Live marquee (`.marquee` + `.mask-x`, duplicated list, hover-pause) fed by SWR from `/api/public/models`; skeleton first; static wrapped grid under reduced motion; hidden on error/empty. | | ||||
| 19 | +| `src/components/marketing/security-flow.tsx` | `SecurityFlow` (Browser → PolyLLM encrypted layer → Provider) and `KeyLifecycle` (5 steps) for `/security`. | | ||||
| 20 | +| `src/components/marketing/security-teaser.tsx` | Homepage `#security` section linking to `/security`. | | ||||
| 21 | +| `src/components/marketing/install-hint.tsx` | "Install PolyLLM" — `beforeinstallprompt` (Chromium/Android) or iOS Share → Add to Home Screen tip; hidden when standalone/dismissed/installed. | | ||||
| 22 | +| `src/app/(marketing)/security/page.tsx` | Security page (see verified claims). | | ||||
| 23 | +| `src/app/(marketing)/contact/page.tsx` | Contact page: e-mail, who built it, hosting, security reports. | | ||||
| 24 | +| `src/app/app/onboarding/page.tsx` + `onboarding-flow.tsx` | Six-step onboarding (details below). | | ||||
| 25 | + | ||||
| 26 | +### Changed | ||||
| 27 | +| File | Change | | ||||
| 28 | +| --- | --- | | ||||
| 29 | +| `src/app/(marketing)/page.tsx` | New section order: Hero → ModelStrip → Router → Arena → Catalog → Config → Workspace → Analytics → Endpoints → Demo → Also → SecurityTeaser → FAQ → FinalCta. Metadata description updated. | | ||||
| 30 | +| `src/components/marketing/hero.tsx` | Headline "One interface. / Every model." (`text-5xl` token, `text-balance`), sub-line, CTAs "Start using PolyLLM" → `/signup`, "Try Arena" → `/app/arena` (proxy → `/login?next=/app/arena` → Arena), provider row, `HeroVisual`. | | ||||
| 31 | +| `src/components/marketing/demo.tsx` | Rewritten: `Segmented` tabs Chat / Arena / Models / Usage, Replay, desktop frame (≥ md) + phone frame (< md and ≥ xl side by side), per-tab note. Desktop UI is never squeezed into the phone. | | ||||
| 32 | +| `src/components/marketing/features.tsx` | Rewritten: `FeatureRouter` (AUTO recommendation card), `FeatureArena` (scoreboard + Blind Arena), `FeatureCatalog`, `FeatureWorkspace` (projects / prompt `{{variables}}` / context library), `FeatureAnalytics`, `FeatureEndpoints` (Ollama, LM Studio, vLLM), `FeatureAlso` (PWA, keyboard, exports, compare-in-chat, presets, temporary chats). Spatial grouping (`.panel`, hairlines) instead of boxed cards. | | ||||
| 33 | +| `src/components/marketing/mock-data.ts` | 9 mock models (one per provider, with tags), hero/chat/arena scripts, usage series/KPIs/savings, scoreboard, FAQ rewritten (router, blind arena, custom endpoints, temporary chats). `PROVIDER_LABEL` is exhaustive over `ProviderId` (includes `custom`). | | ||||
| 34 | +| `src/components/marketing/footer.tsx` | Columns Product / Models / Account / Legal; "Made by" block: **contact@spboucher.ai** (mailto), **Simon-Pierre Boucher**, **Hosted on MacLustr — www.maclustr.io** (external); providers trademark note; `InstallHint` (compact). | | ||||
| 35 | +| `src/components/marketing/header.tsx` | Nav: Features, Demo, Models (`/models`), Security (`/security`), FAQ. | | ||||
| 36 | +| `src/components/marketing/legal.tsx` | Contact e-mail aligned to contact@spboucher.ai. | | ||||
| 37 | +| `src/components/marketing/providers-row.tsx` | Removed (superseded by the provider row in the hero and the live strip). | | ||||
| 38 | +| `src/app/sitemap.ts` | Added `/models` (daily, 0.9), `/security` (0.7), `/contact` (0.4). | | ||||
| 39 | +| `src/components/app/onboarding.tsx` | `Onboarding` export kept. Now a first-run gate: redirects once per browser session to `/app/onboarding` when `onboardingCompletedAt === null` and the (loaded) connections list is empty; afterwards renders a small "Finish setting up PolyLLM" link. Uses the same SWR key as the store (`/api/providers`) to wait for `isLoading`. | | ||||
| 40 | +| `src/app/(auth)/layout.tsx` | Mobile-first shell: brand mark + wordmark above the form on phones, safe-area padding (`pt-safe`, `pb-[max(20px,var(--sab))]`), footer links (Privacy, Terms, Security, Help). Desktop ≥ lg: two columns, quiet brand panel (headline, 3 points, provider marks). | | ||||
| 41 | +| `src/app/(auth)/_components/auth-card.tsx` | Borderless full-width on phones (26 px title, 15 px copy, 44 px inputs, 48 px submit), elevated card from `sm`. Form logic untouched. | | ||||
| 42 | + | ||||
| 43 | +## API | ||||
| 44 | + | ||||
| 45 | +`GET /api/public/models` — no auth. Headers `Cache-Control: public, max-age=600, s-maxage=600, stale-while-revalidate=300`. | ||||
| 46 | + | ||||
| 47 | +Response `200`: | ||||
| 48 | +```json | ||||
| 49 | +{ "models": [ { "key": "openai/gpt-5.5", "displayName": "GPT-5.5", "provider": "openai", "contextTokens": 400000, | ||||
| 50 | + "inputPerMillion": 1.25, "outputPerMillion": 10, "status": "active", "firstSeenAt": "2026-08-30T…Z" } ], | ||||
| 51 | + "total": 132, "generatedAt": "…" } | ||||
| 52 | +``` | ||||
| 53 | +- ≤ 40 entries, only `hidden = false` and `status ∈ {active, preview}`. | ||||
| 54 | +- Ordering: models first seen in the last 30 days first, then by registry `sortWeight` (flagships), then newest; the ranked | ||||
| 55 | + list is interleaved per provider so the first screen shows all providers. | ||||
| 56 | +- `503 { models: [] }` (cached 60 s) if the database is unavailable; the strip hides itself. | ||||
| 57 | +- Reads the `models` table directly (`@/db`) — no change to `lib/ai/registry` (B's area). | ||||
| 58 | + | ||||
| 59 | +## Onboarding (`/app/onboarding`) | ||||
| 60 | + | ||||
| 61 | +Client component, inside the app shell (the shell already hides the bottom nav on this route). Six steps: | ||||
| 62 | + | ||||
| 63 | +1. **Create account** — already done → green check with name/e-mail. | ||||
| 64 | +2. **Choose providers** — grid of tiles (`PROVIDER_ORDER`, `PROVIDERS` meta), multi-select with `aria-pressed`; connected ones show "Connected". | ||||
| 65 | +3. **Add first API key** — rows for chosen + connected providers; "Get a key" (provider console) and "Add key" → `useAddKeyDialog` / `AddKeyDialog` (E's component, unchanged). Continue requires ≥ 1 valid connection; Skip otherwise. | ||||
| 66 | +4. **Validate key** — the dialog result (`ok`, `modelsAvailable`, `error`) is recorded as the validation; "Test connection" calls the existing `POST /api/providers {action:"validate"}`; status Valid / Rejected / Not validated with hint, models count, latency, relative time; "Replace key" on failure. | ||||
| 67 | +5. **Select favorite models** — connected, non-deprecated models grouped per provider (6 per provider, filter input), star toggles `useApp().toggleFavorite` (44 px rows). | ||||
| 68 | +6. **Send first prompt** — four suggested prompts + "Open the chat". `finish()` = `updatePreferences({ onboardingCompleted: true })`, localStorage marker `polyllm:onboarding-done:<userId>`, `setSelectedModelKey(firstFavorite)`, `router.replace('/app/chat?model=<key>&q=<prompt>')`, `router.refresh()`. | ||||
| 69 | + | ||||
| 70 | +Layout: phones = progress bar + swipeable `.snap-row` panels (`useSnapCarousel`, each panel scrolls on its own) + fixed footer with 44 px buttons (Back / Skip / Continue) above the safe area; desktop = centered two-column (sticky step list with check marks, content column). "Skip setup" in the header marks onboarding complete and returns to chat. | ||||
| 71 | + | ||||
| 72 | +**TODO(integration: chat)** — `ChatView` currently applies `?preset=`, `?prompt=` (prompt preset id) and `?model=`. The onboarding passes the suggested text as `?q=`; add one line in `components/chat/chat-view.tsx` to prefill the composer draft from `search.get("q")` (e.g. `if (q && !conversationId) setDraft(q)` in the existing search-params effect). Until then the user lands on a new chat with the favorite model selected and an empty composer. | ||||
| 73 | + | ||||
| 74 | +## Verified security claims (source references) | ||||
| 75 | + | ||||
| 76 | +Every statement on `/security`, the homepage security teaser and the FAQ was checked against the code on 2026-09-11: | ||||
| 77 | + | ||||
| 78 | +| Claim | Where verified | | ||||
| 79 | +| --- | --- | | ||||
| 80 | +| AES-256-GCM, data key derived with HKDF-SHA256 from `API_KEY_ENCRYPTION_SECRET` (info `polyllm:provider-key:v1`), random 12-byte IV, 16-byte auth tag, AAD `userId\|provider`, envelope `v1.<iv>.<tag>.<ct>` base64url | `src/lib/crypto/keys.ts` (`dataKey`, `encryptSecret`, `decryptSecret`) | | ||||
| 81 | +| Key hint = recognizable prefix (≤ 8 chars) + `••••••••` + last 4; SHA-256 fingerprint for same-key detection | `keyHint`, `fingerprintSecret` in `src/lib/crypto/keys.ts` | | ||||
| 82 | +| Key validated against the provider on save; shape check 8–512 chars, no whitespace | `upsertConnection` in `src/lib/providers/keys.ts`; `PUT /api/providers` passes `validate: true` | | ||||
| 83 | +| Decrypted only right before a provider call (`getDecryptedKey`, `validateConnection`) | `src/lib/providers/keys.ts`; callers `api/providers`, `api/models/sync`, chat/arena services | | ||||
| 84 | +| `PublicConnection` has no key material | `src/lib/providers/keys.ts` interface | | ||||
| 85 | +| Audit events `provider.key_added` / `provider.key_replaced` / `provider.key_deleted` with IP | `src/lib/providers/keys.ts` → `writeAudit` (`src/lib/audit.ts`: action, ip, UA truncated to 300, meta) | | ||||
| 86 | +| Other audit actions: `account.created`, `login` (ip + UA), `email.verified`, `email.change_requested`, `password.reset_requested`, `password.reset`, `account.deleted`; conversation actions `export`, `share`, `branch`, `duplicate` | `src/lib/auth.ts` hooks; `src/app/api/conversations/[id]/actions/route.ts` | | ||||
| 87 | +| Audit log shown to the user (Settings → Account, `GET /api/account`) — **not** included in the JSON export | `src/app/app/settings/account/page.tsx`, `src/app/api/account/route.ts` | | ||||
| 88 | +| Logs: structured JSON, `redactSecrets` on every string, keys matching api key/password/secret/token/authorization/cookie/prompt/content/messages → `[redacted]` | `src/lib/log.ts` | | ||||
| 89 | +| Passwords: Argon2id, memoryCost 19 456 KiB (19 MiB), timeCost 2, parallelism 1; min 10 / max 128 chars | `ARGON2_OPTS` and `emailAndPassword` in `src/lib/auth.ts` | | ||||
| 90 | +| E-mail verification required, no auto sign-in on sign-up, verification link 24 h, reset link 60 min, sessions revoked on password reset, change e-mail / delete account confirmed by e-mail | `src/lib/auth.ts` (`requireEmailVerification`, `autoSignIn: false`, `expiresIn`, `resetPasswordTokenExpiresIn`, `revokeSessionsOnPasswordReset`, `changeEmail`, `deleteUser`) | | ||||
| 91 | +| Cookies: prefix `polyllm`, Secure in production, HttpOnly, SameSite=Lax; session 30 d, refreshed daily, 5-min cookie cache | `advanced.cookiePrefix`, `useSecureCookies: IS_PROD`, `session` in `src/lib/auth.ts`; defaults `httpOnly: true`, `sameSite: "lax"` in `node_modules/better-auth/dist/cookies/index.mjs` | | ||||
| 92 | +| Auth accepted only from the app origin | `trustedOrigins: [APP_URL, "http://localhost:3000"]` | | ||||
| 93 | +| Auth rate limits: 100/min default; sign-in 8, sign-up 4, forget/request-reset 4, send-verification 3, change-password 5, change-email 3, delete-user 3 (per minute) | `rateLimit.customRules` in `src/lib/auth.ts` | | ||||
| 94 | +| App rate limits (per user id, in-memory sliding window, 429 + `Retry-After`): chat 60, arena 20, arena stream 80, key save 12, key validate 10, model sync 6, upload 40, search 120, share/conversation actions 20 per minute | `LIMITS` in `src/lib/rate-limit.ts`; `withUser` in `src/lib/api.ts`; route files under `src/app/api/*` | | ||||
| 95 | +| CSP `default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; img-src 'self' data: blob: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' (prod); connect-src 'self'; worker-src 'self' blob:; upgrade-insecure-requests` | `next.config.ts` | | ||||
| 96 | +| HSTS `max-age=31536000; includeSubDomains` (prod), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()`, `poweredByHeader: false` | `next.config.ts` | | ||||
| 97 | +| Unauthenticated `/app` and `/admin` redirected to `/login?next=` | `src/proxy.ts` | | ||||
| 98 | +| Owner keys only for registry sync / admin / tests | `CLAUDE.md` non-negotiables; `syncAllWithEnvKeys` in `src/lib/ai/registry/index.ts` | | ||||
| 99 | + | ||||
| 100 | +Stated as a trade-off on the page: `'unsafe-inline'` in `script-src`/`style-src` (Next hydration + Radix). Not claimed anywhere: | ||||
| 101 | +encrypted backups, nonce-based CSP, Redis-backed limits. | ||||
| 102 | + | ||||
| 103 | +## Coming soon / not implemented | ||||
| 104 | + | ||||
| 105 | +Nothing is labelled "Coming soon". All buttons work. The only pending hook is the `?q=` draft prefill in ChatView (above). | ||||
| 106 | + | ||||
| 107 | +## Notes for other areas | ||||
| 108 | + | ||||
| 109 | +- **E (settings/appearance):** `components/marketing/install-hint.tsx` is reusable (`<InstallHint compact />`); it stores dismissal in `localStorage["polyllm:install-hint-dismissed"]`. | ||||
| 110 | +- **B (models):** the footer and header link to `/models`; the hero strip links "Browse all" to `/models`. `formatContext`/`formatPricePair` in `src/lib/marketing/public-models.ts` are generic if useful. | ||||
| 111 | +- **A (chat):** `?q=` prefill (see above). The `Onboarding` component is still mounted from `components/chat/empty-state.tsx`; it now renders either nothing or a small link, so the empty state's own "Connect your first provider" card remains the primary CTA there. | ||||
| 112 | +- Lint errors remaining in the tree at the time of writing are in G's files (`components/search/search-sheet.tsx`, `components/app/command-palette.tsx`), not in this area. | ||||
| 113 | + | ||||
| 114 | +## QA checklist (integration phase, 375 / 390 / 393 / 430 / 1440) | ||||
| 115 | + | ||||
| 116 | +Homepage | ||||
| 117 | +- [ ] Hero headline wraps as two lines on phones without orphan; CTAs full-width on phones, inline from `sm`. | ||||
| 118 | +- [ ] Phone frame (< md) shows header with model pill, user bubble, streaming answer with caret, metadata line, composer `[+] Ask anything… [mic] [send]`, bottom nav with Chat active; no horizontal overflow at 375. | ||||
| 119 | +- [ ] Desktop (≥ md) shows the window with sidebar, model pill, context indicator, streaming answer; mini Arena card appears bottom-right at ≥ lg after ~3 s and its bars finish; at 1440 the card does not overflow the container (`xl:-right-10`). | ||||
| 120 | +- [ ] Live strip: skeleton → marquee; pauses on hover; "New"/"Preview" badges; `prefers-reduced-motion` → static wrapped grid; strip hidden when `/api/public/models` returns 503 (stop the DB to test). | ||||
| 121 | +- [ ] `/api/public/models` returns ≤ 40 models, `Cache-Control` header present, no auth needed, no user fields. | ||||
| 122 | +- [ ] Features: no boxed-card overload; visuals fit at 375 (Router card, Scoreboard + Blind Arena stack, Models/Usage windows without the sidebar, Workspace panel, Endpoints list). | ||||
| 123 | +- [ ] Demo: Segmented fills the width on phones; tabs switch instantly; Replay restarts streams; phone Arena swipes between the three answers and the tab pills/dots follow; desktop Arena shows three columns, "fastest" badge, vote pills, Winner badge when done; Models/Usage tabs render both frames at ≥ xl. | ||||
| 124 | +- [ ] `#features`, `#arena`, `#demo`, `#security`, `#faq` anchors land correctly with the sticky header (`scroll-mt-16`). | ||||
| 125 | +- [ ] Footer: four columns collapse to 2 columns on phones; Made-by block shows mailto, name, MacLustr external link (new tab); Install hint appears only on Chromium (`beforeinstallprompt`) or iOS Safari, disappears when dismissed or in standalone mode. | ||||
| 126 | + | ||||
| 127 | +Security / Contact | ||||
| 128 | +- [ ] `/security` flow renders as a vertical stack with down arrows on phones and a 3-column row at ≥ lg; header `dl` rows wrap long CSP values without overflow; rate-limit tables are two columns at ≥ md. | ||||
| 129 | +- [ ] `/contact` mailto works; external MacLustr link opens in a new tab. | ||||
| 130 | +- [ ] `/sitemap.xml` lists `/models`, `/security`, `/contact`. | ||||
| 131 | + | ||||
| 132 | +Onboarding | ||||
| 133 | +- [ ] New user (no providers, `onboardingCompletedAt` null) opening `/app/chat` is redirected once to `/app/onboarding`; navigating back to chat in the same session shows the "Finish setting up PolyLLM" link instead of redirecting again. | ||||
| 134 | +- [ ] Phone: progress bar, swipe between steps (`.snap-row`), footer buttons 44 px above the safe area, bottom nav hidden, each step scrolls independently, no horizontal overflow at 375. | ||||
| 135 | +- [ ] Desktop: sticky step list with check marks; clicking a step jumps to it. | ||||
| 136 | +- [ ] Step 3 opens `AddKeyDialog`; after a successful save, step 4 shows "Valid · N models"; "Test connection" updates status and shows latency; a rejected key shows the provider error and "Replace key". | ||||
| 137 | +- [ ] Step 5 stars persist (`/api/models` favorites) and the count updates; filter input works. | ||||
| 138 | +- [ ] Step 6 / "Open the chat" marks onboarding completed (`users.onboarding_completed_at`), selects the first favorite model and opens `/app/chat?model=…&q=…`; "Skip setup" does the same without a prompt. | ||||
| 139 | + | ||||
| 140 | +Auth | ||||
| 141 | +- [ ] Login/signup/forgot/reset/verify at 375: brand mark + wordmark above the form, no card border, 44 px inputs (16 px text on touch), 48 px submit, password strength meter, footer above the home indicator. | ||||
| 142 | +- [ ] ≥ lg: brand panel left, form right; the header logo is invisible on the right column (panel has its own). | ||||
| 143 | +- [ ] All existing behaviours unchanged: unverified → resend flow, `?next=` redirect after login, notices (`reset`, `verified`, `signed-out`). | ||||
| 144 | + | ||||
| 145 | +PWA | ||||
| 146 | +- [ ] `/manifest.webmanifest` linked from the layout (already); Lighthouse "installable" passes; "Install PolyLLM" hint on the homepage footer (Chromium) triggers the native prompt. | ||||
added
docs/upgrade-notes/G-search-share.md
+169 −0
@@ -0,0 +1,169 @@ | |||||
| 1 | +# G — Search, command palette, share & export | ||||
| 2 | + | ||||
| 3 | +Workstream G of the 2026-09-11 upgrade (`docs/UPGRADE-PLAN.md`). Everything below compiles (`pnpm typecheck`), | ||||
| 4 | +lints (`eslint` on the files listed) and is covered by vitest (`tests/unit/search-query.test.ts`, | ||||
| 5 | +`tests/unit/export-html.test.ts`, `tests/integration/search-share.test.ts` — the last one runs against the local | ||||
| 6 | +Postgres when `DATABASE_URL` is reachable). | ||||
| 7 | + | ||||
| 8 | +## Files | ||||
| 9 | + | ||||
| 10 | +| Area | File | What | | ||||
| 11 | +| --- | --- | --- | | ||||
| 12 | +| Query language | `src/lib/search/query.ts` | Pure `parseQuery()`, `addFilter/removeFilter/stripFilters`, `highlightSegments`, `makeSnippet`, `stripMarkdown`, `prefixTsQuery`, `escapeLike`, `parseDateToken` (unit-tested) | | ||||
| 13 | +| Search service | `src/lib/search/service.ts` | `searchAll(userId, raw, { limit, cursor, groups })` — Postgres FTS (`to_tsvector('simple')` + `websearch_to_tsquery` + prefix `to_tsquery`) with ILIKE fallback, filter resolution, opaque cursor pagination, grouped results | | ||||
| 14 | +| API | `src/app/api/search/route.ts` | `GET /api/search` (rewritten, same URL as before, superset response) | | ||||
| 15 | +| Migration | `drizzle/0003_search_index.sql` + `drizzle/meta/_journal.json` (idx 3) | GIN expression indexes on `messages.content` and `conversations.title`, partial index on active shares. **Applied locally with `pnpm db:migrate`.** | | ||||
| 16 | +| Conversations service (additive) | `src/lib/conversations/service.ts` | `exportConversation(format: json\|markdown\|txt\|html, { print, appUrl })`, `shareConversation(userId, id, { messageIds })`, `listShares`, `revokeShareById`, `readShareMeta`, `getPublicShare(id, { peek })`, `EXPORT_FORMATS`, types `ExportFormat`, `ShareLinkItem`, `ShareSnapshotMeta` | | ||||
| 17 | +| Export renderer | `src/lib/export/markdown-html.ts`, `src/lib/export/html-document.ts` | Dependency-free, escaped Markdown → HTML (GFM subset) and the self-contained print document (inline CSS, brand mark, page-break rules) | | ||||
| 18 | +| Actions route (additive) | `src/app/api/conversations/[id]/actions/route.ts` | `export` accepts the 4 formats; `share` accepts `{ messageIds? }`; new `list-shares`; `unshare` accepts `{ shareId? }`; `share-status` also returns `shares` | | ||||
| 19 | +| New routes | `src/app/api/conversations/[id]/export/route.ts`, `src/app/api/shares/route.ts` | See API section | | ||||
| 20 | +| Palette | `src/components/app/command-palette.tsx` | Universal ⌘K palette (root commands + sub-lists + search mode), `ShortcutsSheet`, mounts `SearchSheet` and `ShareSheetHost` | | ||||
| 21 | +| Search UI | `src/components/search/search-sheet.tsx`, `use-search.ts`, `hits.tsx`, `filter-chips.tsx`, `highlight.tsx` | Phone full-screen sheet, SWR hook with pagination + recent searches, hit rows + navigation, filter chips with picker sheets, `<Highlight />` | | ||||
| 22 | +| Share UI | `src/components/share/share-sheet.tsx` | `ShareSheet`, `useShareSheet()`, `openShareSheet()`, `ShareSheetHost`, `ShareLinksList` | | ||||
| 23 | +| Export UI | `src/components/share/export.ts`, `src/components/share/export-menu.tsx` | `exportConversation(id, format)` client helper, `EXPORT_OPTIONS`, `ExportMenu` (dropdown / ActionSheet), `EXPORT_ICONS` | | ||||
| 24 | +| Share page | `src/app/share/[id]/page.tsx` | Mobile-first redesign, excerpt badge, view count, `noindex`, per-share OG/Twitter title | | ||||
| 25 | +| Types (append-only) | `src/lib/client/types.ts` | Re-exports `SearchResponse`, hit types, `ShareLinkItem`, `ExportFormat`, `ParsedQuery`, `FilterToken`, `FilterKey` | | ||||
| 26 | +| Tests | `tests/unit/search-query.test.ts`, `tests/unit/export-html.test.ts`, `tests/integration/search-share.test.ts` | 18 + 11 unit, 6 integration | | ||||
| 27 | + | ||||
| 28 | +`src/lib/export/*` is a new directory not claimed by any workstream (used only by the conversations service). | ||||
| 29 | + | ||||
| 30 | +## Query language | ||||
| 31 | + | ||||
| 32 | +``` | ||||
| 33 | +free text "exact phrase" model:claude provider:openai project:research project:"Q3 Research" | ||||
| 34 | +folder:clients after:2026-08-01 after:2026-08 after:7d after:today after:yesterday after:month | ||||
| 35 | +before:2026-09-01 role:user|assistant is:pinned is:archived is:shared | ||||
| 36 | +``` | ||||
| 37 | + | ||||
| 38 | +- `model:` substring of `modelKey` **or** of a registry display name (resolved server-side); `provider:` accepts ids and | ||||
| 39 | + aliases (`google`→gemini, `claude`→anthropic, `grok`→xai…); `project:`/`folder:` match id or name substring; a filter | ||||
| 40 | + that matches nothing returns an empty result (not "everything"). | ||||
| 41 | +- `after:` is inclusive (UTC midnight), `before:` exclusive; `after:2026-08` = whole month; relative `Nd|Nw|Nm|Ny`. | ||||
| 42 | +- `role:` applies to messages and hides the Conversations group. `is:archived` is the only way to see archived chats? | ||||
| 43 | + No — archived conversations are searchable by default; `is:archived` restricts to them. | ||||
| 44 | +- Unknown `key:value` tokens (URLs, `10:30`) and invalid values (`role:pirate`) are kept as free text. | ||||
| 45 | +- Words ≥ 2 chars **or** any filter make a query searchable. `websearch_to_tsquery('simple')` handles phrases and `-neg`; | ||||
| 46 | + a prefix query (`'quot':*`) is OR-ed so the last half-typed word matches; if FTS returns nothing on the first page an | ||||
| 47 | + ILIKE fallback (all words AND-ed) catches fragments inside words (`configur` → "Reconfiguring"). | ||||
| 48 | + | ||||
| 49 | +## API | ||||
| 50 | + | ||||
| 51 | +### `GET /api/search?q=&limit=12&cursor=&groups=` | ||||
| 52 | +Auth: user. Rate limit `LIMITS.search`. `groups` = comma list of `conversations,messages,models,prompts,projects,presets` | ||||
| 53 | +(default all; secondary groups only on the first page). | ||||
| 54 | + | ||||
| 55 | +```ts | ||||
| 56 | +interface SearchResponse { | ||||
| 57 | + query: { raw; text; terms: string[]; tokens: FilterToken[]; hasFilters }; | ||||
| 58 | + conversations: { id; title; modelKey; provider; projectId; folderId; pinned; archived; shared; messageCount; updatedAt; lastMessageAt }[]; | ||||
| 59 | + messages: { id; conversationId; title; role; modelKey; createdAt; snippet }[]; // snippet = plain text, highlight with query.terms | ||||
| 60 | + models: { key; displayName; provider; status }[]; | ||||
| 61 | + prompts: { id; name; description; kind: "library" | "legacy" }[]; // library = prompts table (D), legacy = prompt_presets | ||||
| 62 | + projects: { id; name; description; icon; color }[]; | ||||
| 63 | + presets: { id; name; description; modelKey }[]; | ||||
| 64 | + nextCursor: string | null; // opaque; pass back as ?cursor= (pages conversations + messages only) | ||||
| 65 | + engine: "fts" | "ilike" | "none"; | ||||
| 66 | + tookMs: number; | ||||
| 67 | +} | ||||
| 68 | +``` | ||||
| 69 | +Prompts are read from the `prompts` table directly (no internal HTTP to `/api/prompts`), so nothing 404s if D is not wired. | ||||
| 70 | + | ||||
| 71 | +### `POST /api/conversations/[id]/actions` (existing, extended) | ||||
| 72 | +- `{ action: "share", messageIds?: string[] }` → `{ id, path: "/share/<id>", partial, messageCount, created }`. | ||||
| 73 | + Without `messageIds` the conversation's single "entire conversation" link is refreshed in place (same URL); with | ||||
| 74 | + `messageIds` a **new** excerpt link is created (active messages only, conversation order). | ||||
| 75 | +- `{ action: "unshare", shareId?: string }` → `{ ok }` (all links of the conversation, or one). | ||||
| 76 | +- `{ action: "share-status" }` → `{ share: { id, createdAt, viewCount } | null, shares: ShareLinkItem[] }`. | ||||
| 77 | +- `{ action: "list-shares" }` → `{ shares: ShareLinkItem[] }` (this conversation). | ||||
| 78 | +- `{ action: "export", format: "json" | "markdown" | "txt" | "html" }` → file (kept for the sidebar). | ||||
| 79 | + | ||||
| 80 | +### `GET /api/conversations/[id]/export?format=json|markdown|txt|html[&download=1][&print=1]` | ||||
| 81 | +File download (`Content-Disposition: attachment`) for everything except `html` without `download=1`, which renders | ||||
| 82 | +inline; `print=1` embeds a `window.print()` on load (used by "PDF (print)"). `Cache-Control: private, no-store`, | ||||
| 83 | +`X-Robots-Tag: noindex`. Rate limit 40/min. | ||||
| 84 | + | ||||
| 85 | +### `GET /api/shares` → `{ shares: ShareLinkItem[] }` · `DELETE /api/shares?id=<shareId>` → `{ ok }` | ||||
| 86 | +```ts | ||||
| 87 | +interface ShareLinkItem { id; conversationId; title; createdAt; viewCount; messageCount; partial; path: "/share/<id>" } | ||||
| 88 | +``` | ||||
| 89 | + | ||||
| 90 | +### Share snapshot v2 | ||||
| 91 | +`shared_conversations.snapshot` now starts with `{ $meta: true, version: 2, partial, selectedCount, totalCount, generatedAt }` | ||||
| 92 | +followed by the messages. Renderers skip elements without `role`; `readShareMeta(snapshot)` reads it. Old snapshots | ||||
| 93 | +(no meta) keep working. `getPublicShare(id, { peek: true })` reads without counting a view (used by `generateMetadata`). | ||||
| 94 | + | ||||
| 95 | +## Contracts for other workstreams | ||||
| 96 | + | ||||
| 97 | +### A — chat header / composer | ||||
| 98 | +- **Share button** → `const share = useShareSheet(); share.open({ conversationId, title, messages })` (from | ||||
| 99 | + `@/components/share/share-sheet`). `messages` (the active `PublicMessage[]` already in memory) avoids a refetch; | ||||
| 100 | + `messageIds` preselects "Selected messages" (use it from the message long-press "Share from here…"). The host is | ||||
| 101 | + already mounted by `CommandPalette` — nothing else to mount. | ||||
| 102 | +- **Export** → `<ExportMenu conversationId={id} title={title} />` from `@/components/share/export-menu` (icon button by | ||||
| 103 | + default; pass your own trigger as children). Programmatic: `exportConversation(id, "pdf")` from `@/components/share/export`. | ||||
| 104 | +- **Events the palette dispatches on `window`:** | ||||
| 105 | + - `polyllm:open-attach` (CustomEvent, no detail) — "Upload file" command. The composer should open its attachment sheet | ||||
| 106 | + (phone) or the file picker (desktop). When the user is not on a chat page the palette navigates to `/app/chat` first | ||||
| 107 | + and dispatches ~450 ms later. | ||||
| 108 | + - `polyllm:switch-model` (`detail: { modelKey: string }`, may be `AUTO_MODEL_KEY`) — "Switch model" and model search hits. | ||||
| 109 | + The store's `selectedModelKey` is already set; the chat view should apply it to the **current** conversation | ||||
| 110 | + (`changeModel(detail.modelKey)`) if one is open. | ||||
| 111 | +- Deep links `/app/chat/<id>#<messageId>`: `chat-view.tsx` already scrolls to `#hash` on mount; when the same conversation | ||||
| 112 | + is open the search UI calls `scrollIntoView` itself. Keep `id={m.id}` on message wrappers. | ||||
| 113 | + | ||||
| 114 | +### E — Settings → Data | ||||
| 115 | +Mount `<ShareLinksList />` from `@/components/share/share-sheet` in a card titled "Active share links" (it renders its | ||||
| 116 | +own empty state, copy/revoke actions and a `ConfirmDialog`). It reads `GET /api/shares`. | ||||
| 117 | + | ||||
| 118 | +### D — prompts / projects | ||||
| 119 | +Search links library prompts to `/app/chat?promptId=<id>` (your insertion contract) and legacy presets to | ||||
| 120 | +`/app/prompts?edit=<id>`; projects to `/app/projects/<id>`. The palette's "Open project" reads `GET /api/projects` | ||||
| 121 | +(tolerates errors) and sets `activeProjectId` before navigating. Filter chips read project names from the same route. | ||||
| 122 | + | ||||
| 123 | +### Shell (integrator) | ||||
| 124 | +"Toggle sidebar" on desktop dispatches a synthetic `⌘B` keydown because the collapsed state lives in `shell.tsx` | ||||
| 125 | +(`// TODO(integration: shell)`): exposing `toggleSidebarCollapsed` in the store would make it explicit. On phones the | ||||
| 126 | +command opens the drawer (`setSidebarOpen(true)`). `store.searchOpen` on ≥ md is consumed by the palette (opens in | ||||
| 127 | +search mode and resets the flag) so `setSearchOpen(true)` works from anywhere on any breakpoint. | ||||
| 128 | + | ||||
| 129 | +## Palette behaviour (desktop keyboard-first, phone bottom sheet) | ||||
| 130 | +- Root: typing filters commands by label/keywords; **≥ 2 characters also shows live search results** under the | ||||
| 131 | + commands; a leading `/` is pure search mode. `⌫` on an empty input returns to the root, `Esc` closes. | ||||
| 132 | +- Commands: New chat, New temporary chat (`/app/chat?temporary=1`), Search conversations, Switch model (AUTO + connected | ||||
| 133 | + models, labels, favorites first, current ✓), Upload file, Open project, Go to Chat / Arena / Models / Usage / Providers / | ||||
| 134 | + Projects / Library / Prompts / Model presets / Settings, Toggle theme (light/dark/system ✓), Toggle/Open sidebar, | ||||
| 135 | + Keyboard shortcuts (sheet). On a chat page: Copy conversation URL, Share conversation, Export conversation (sub-list). | ||||
| 136 | +- Recent searches (localStorage `polyllm:recent-searches`, 8 max) and syntax examples appear in empty search mode. | ||||
| 137 | + | ||||
| 138 | +## Migration notes | ||||
| 139 | +- `drizzle/0003_search_index.sql` is hand-written (expression indexes cannot be declared from `schema-search.ts` on | ||||
| 140 | + tables defined in `schema.ts`); numbering follows E's `0002_endpoints`. No snapshot file is needed (the indexes are | ||||
| 141 | + not part of the drizzle schema, so `drizzle-kit generate` will neither re-create nor drop them; `drizzle-kit push` is | ||||
| 142 | + not used). Index expressions **must** stay identical to `msgTsv`/`titleTsv` in `src/lib/search/service.ts`. | ||||
| 143 | +- Plain `CREATE INDEX IF NOT EXISTS` (the migrator runs in a transaction; `CONCURRENTLY` is not possible). On prod the | ||||
| 144 | + `messages` table is small; expect sub-second creation via the mld hook. | ||||
| 145 | +- Verified with `EXPLAIN`: Bitmap Index Scan on `messages_content_fts_idx` for both the websearch and the prefix query. | ||||
| 146 | + | ||||
| 147 | +## Not done / Coming soon | ||||
| 148 | +- Nothing is labelled "Coming soon". Desktop filter chips are shown inside the palette's search mode (compact); the | ||||
| 149 | + pickers are the same sheets as on phones. | ||||
| 150 | +- Tags (`tag:`) are not a filter — `conversation_tags` has no UI yet. | ||||
| 151 | + | ||||
| 152 | +## QA checklist (integration phase, 375/390/393/430 + 1440) | ||||
| 153 | +1. Phone: sidebar search icon → full-screen sheet, keyboard opens, 16 px input, no zoom; chips scroll horizontally; | ||||
| 154 | + Model/Project/Date/Role/Status pickers open as half sheets and append tokens; token chips remove on ×. | ||||
| 155 | +2. Type `quotas` (or any word from a real chat): grouped results, `<mark>` highlights, tap a message → chat opens and | ||||
| 156 | + scrolls to the message; recent search saved; "Load more" appears when > 10 message hits. | ||||
| 157 | +3. Desktop ⌘K: ↑↓↵ across commands and results, `/` search mode, `⌫` back, Esc close; palette sits at 14 % from the top, | ||||
| 158 | + max 72 vh; footer hints. Hybrid: typing "arena" shows the command **and** matching chats. | ||||
| 159 | +4. Switch model from the palette on an open chat → chat header model changes (needs A's `polyllm:switch-model` listener). | ||||
| 160 | + Upload file → composer attach opens (needs A's `polyllm:open-attach` listener). | ||||
| 161 | +5. Share sheet (chat header / palette): warning visible, Entire vs Selected (checkbox list with role + snippet, All/None), | ||||
| 162 | + Create → link copied + toast, Copy/Open/Share… (native share on phones), Active links list, revoke via ConfirmDialog. | ||||
| 163 | + Sharing the whole conversation twice keeps the same URL; a selection creates a second link. | ||||
| 164 | +6. `/share/<id>`: renders on 375 px without horizontal overflow, excerpt badge for partial shares, view count increments | ||||
| 165 | + once per visit, `<meta name="robots" content="noindex">`, OG title = conversation title; revoked link → 404. | ||||
| 166 | +7. Export: Markdown/TXT/JSON/HTML download with the right filename; "PDF (print)" opens a new tab with the print dialog | ||||
| 167 | + (Safari/Chrome/iOS Safari); blocked pop-up → HTML download + warning toast. | ||||
| 168 | +8. Settings → Data: `ShareLinksList` rows stack correctly on phones, revoke works, empty state text. | ||||
| 169 | +9. Old sidebar actions (Copy share link, Export Markdown/JSON) still work through the actions route. | ||||
added
drizzle/0002_endpoints.sql
+2 −0
@@ -0,0 +1,2 @@ | |||||
| 1 | +ALTER TABLE "custom_endpoints" ADD COLUMN "discovered_models" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint | ||||
| 2 | +ALTER TABLE "custom_endpoints" ADD COLUMN "discovered_at" timestamp with time zone; | ||||
| \ No newline at end of file | |||||
added
drizzle/0003_search_index.sql
+8 −0
@@ -0,0 +1,8 @@ | |||||
| 1 | +-- Full-text search indexes (workstream G). Expression indexes cannot be declared on tables owned by | ||||
| 2 | +-- another schema file, so this migration is hand-written. The expressions MUST match the ones used in | ||||
| 3 | +-- src/lib/search/service.ts (`msgTsv` / `titleTsv`) for the planner to pick them up. | ||||
| 4 | +CREATE INDEX IF NOT EXISTS "messages_content_fts_idx" ON "messages" USING gin (to_tsvector('simple', coalesce("content", ''))); | ||||
| 5 | +--> statement-breakpoint | ||||
| 6 | +CREATE INDEX IF NOT EXISTS "conversations_title_fts_idx" ON "conversations" USING gin (to_tsvector('simple', coalesce("title", ''))); | ||||
| 7 | +--> statement-breakpoint | ||||
| 8 | +CREATE INDEX IF NOT EXISTS "shared_conversations_user_active_idx" ON "shared_conversations" ("user_id", "created_at") WHERE "revoked_at" IS NULL; | ||||
added
drizzle/meta/0002_snapshot.json
+3674 −0
Large diff (3,675 lines) is not loaded by default to keep the page fast.Load diff
modified
drizzle/meta/_journal.json
+14 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified
package.json
+3 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified
pnpm-lock.yaml
+159 −0
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
added
qa/responsive-qa.mjs
+148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
modified
scripts/provider-matrix.ts
+1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/(auth)/_components/auth-card.tsx
+11 −7
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/(auth)/layout.tsx
+78 −23
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/(marketing)/compare/[slug]/page.tsx
+120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/(marketing)/contact/page.tsx
+93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/(marketing)/models/page.tsx
+228 −0
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/(marketing)/page.tsx
+12 −8
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/(marketing)/security/page.tsx
+254 −0
Large diff (254 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/arena/[id]/export/route.ts
+17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/arena/[id]/route.ts
+14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/arena/[id]/share/route.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/arena/scoreboard/route.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/arena/vote/route.ts
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/chat/adopt/route.ts
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/chat/route.ts
+5 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/conversations/[id]/actions/route.ts
+19 −9
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/conversations/[id]/export/route.ts
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/conversations/[id]/route.ts
+1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/conversations/route.ts
+2 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/endpoints/[id]/route.ts
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/endpoints/[id]/sync/route.ts
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/endpoints/[id]/test/route.ts
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/endpoints/route.ts
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/library/files/[id]/route.ts
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/library/files/attach/route.ts
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/library/files/route.ts
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/models/route.ts
+6 −2
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/projects/[id]/route.ts
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/projects/route.ts
+17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/prompts/[id]/route.ts
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/prompts/[id]/use/route.ts
+17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/prompts/route.ts
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/providers/test/route.ts
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/public/models/route.ts
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/search/route.ts
+19 −21
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/shares/route.ts
+19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/api/usage/export.csv/route.ts
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/usage/route.ts
+6 −6
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/arena/scoreboard/page.tsx
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/library/page.tsx
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/models/compare/page.tsx
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/models/page.tsx
+403 −423
Large diff (952 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/onboarding/onboarding-flow.tsx
+593 −0
Large diff (593 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/onboarding/page.tsx
+17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/presets/page.tsx
+42 −61
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/projects/[id]/page.tsx
+13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/projects/page.tsx
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/prompts/page.tsx
+10 −264
Large diff (280 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/settings/account/page.tsx
+42 −9
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/settings/appearance/page.tsx
+10 −6
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/settings/data/page.tsx
+16 −8
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/app/settings/endpoints/page.tsx
+144 −0
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/settings/layout.tsx
+88 −28
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/settings/page.tsx
+5 −3
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/settings/providers/page.tsx
+59 −126
Large diff (236 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/settings/security/page.tsx
+20 −10
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/app/usage/page.tsx
+6 −269
Large diff (281 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/share/[id]/page.tsx
+94 −62
Large diff (247 lines) is not loaded by default to keep the page fast.Load diff
added
src/app/share/arena/[id]/page.tsx
+216 −0
Large diff (216 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/sitemap.ts
+3 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/app/command-palette.tsx
+546 −177
Large diff (764 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/app/onboarding.tsx
+48 −96
Large diff (159 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/arena/arena-column.tsx
+63 −102
Large diff (253 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/arena/arena-history.tsx
+186 −61
Large diff (289 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/arena/arena-view.tsx
+430 −204
Large diff (808 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/blind.tsx
+49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/comparison-table.tsx
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/download.ts
+28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/metrics-strip.tsx
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/arena/model-picker.tsx
+14 −9
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/model-tabs.tsx
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/scoreboard-view.tsx
+182 −0
Large diff (182 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/share-sheet.tsx
+124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/shared-arena-view.tsx
+128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/arena/types.ts
+35 −4
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/vote-panel.tsx
+99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/arena/winner-card.tsx
+78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/brand/provider-icon.tsx
+9 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/chat-view.tsx
+638 −125
Large diff (987 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/compare-inline.tsx
+392 −0
Large diff (392 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/composer-sheets.tsx
+187 −0
Large diff (187 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/composer.tsx
+321 −83
Large diff (485 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/context-indicator.tsx
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/cost-confirm.tsx
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/empty-state.tsx
+57 −21
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/message-error.tsx
+98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/message.tsx
+186 −108
Large diff (456 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/model-badges.tsx
+60 −23
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/model-config.tsx
+354 −227
Large diff (658 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/model-launcher.tsx
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/model-selector.tsx
+516 −189
Large diff (757 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/router-card.tsx
+98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/chat/use-speech.ts
+92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/endpoints/endpoint-row.tsx
+151 −0
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/endpoints/endpoint-sheet.tsx
+355 −0
Large diff (355 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/endpoints/presets.ts
+19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/library/file-library-picker.tsx
+92 −44
Large diff (193 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/library/file-list.tsx
+271 −0
Large diff (271 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/library/format.ts
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/library/library-view.tsx
+150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/library/upload-button.tsx
+86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added
src/components/library/use-in-chat.ts
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/markdown/code-block.tsx
+69 −17
Large diff (133 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/markdown/markdown.tsx
+35 −3
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/marketing/demo.tsx
+3 −39
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
Diff truncated — file too large.