SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%

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>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent c617fec

121 changed files +15,033 −2,891

modified .env.example +7 −0
@@ -33,6 +33,13 @@ KIMI_API_KEY=
33 33 OPENROUTER_API_KEY=
34 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 43 # --- Admin ---------------------------------------------------------------
37 44 # Comma-separated emails with access to /admin.
38 45 ADMIN_EMAILS=
modified docs/deployment-cluster.md +14 −14
@@ -1,17 +1,18 @@
1 1 # PolyLLM — cluster deployment (MacLustr)
2 2
3 −Production runs on the owner's private Apple Silicon cluster and is exposed through ngrok at
4 −**https://www.polyllm.io**. Everything is orchestrated by `mld` (maclustr-dispatch) from the gateway node M1M32.
3 +Production runs on the owner's private Apple Silicon cluster and is exposed through the **MacLustr Tunnel**
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 11 | Item | Value |
11 12 | --- | --- |
12 13 | Node | M3U96a (pinned: local PostgreSQL 17, same host as fetcha / spinza / rareindex) |
13 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 16 | Database | `postgres://localhost:5432/polyllm` (created by the post-sync hook if missing) |
16 17 | Manifest | `M1M32:~/dispatch/apps/polyllm.json` — **the only place secrets live** (gitignored copy `deploy/polyllm.mld.json`) |
17 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 40 `mld deploy` runs the manifest hooks on the node: `pnpm install --frozen-lockfile` → `createdb polyllm` (if needed) +
40 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 44 Restart / logs on the node:
44 45
45 46 ```bash
46 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 51 Migrations are applied by the hook before the new build starts; they must stay backward compatible with the running
51 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 − The apex `polyllm.io` should redirect to `https://www.polyllm.io` (GoDaddy forwarding).
57 −- TLS terminates at ngrok (automatic certificate for the reserved domain). The app sees `X-Forwarded-For` /
58 − `X-Forwarded-Proto`; Better Auth is configured with `baseURL = PUBLIC_APP_URL` and secure cookies in production.
59 −- The PM2 process `polyllm-ngrok` runs `ngrok http 8240 --domain www.polyllm.io`. Only port 8240 is exposed; no admin port.
60 −- Streaming: SSE responses send a `: ping` heartbeat every 15 s so ngrok never closes idle long reasoning turns.
56 +- DNS (GoDaddy): `www.polyllm.io` A → `51.161.112.61` (BHS64). The apex `polyllm.io` redirects to `https://www.polyllm.io`.
57 +- TLS terminates on Caddy (Let's Encrypt, automatic). The app sees `X-Forwarded-For` / `X-Forwarded-Proto`; Better Auth is
58 + 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.
60 +- `mld heal` (every 5 min on M1M32) re-creates the wg1 peer / Caddy route if they disappear after an outage.
61 61
62 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 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 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
@@ -0,0 +1,3674 @@
1 +{
2 + "id": "c05dbcab-c5d0-478f-97aa-c36d13d0fce4",
3 + "prevId": "37eed628-caf4-40a5-a511-f6aad208f087",
4 + "version": "7",
5 + "dialect": "postgresql",
6 + "tables": {
7 + "public.accounts": {
8 + "name": "accounts",
9 + "schema": "",
10 + "columns": {
11 + "id": {
12 + "name": "id",
13 + "type": "text",
14 + "primaryKey": true,
15 + "notNull": true
16 + },
17 + "account_id": {
18 + "name": "account_id",
19 + "type": "text",
20 + "primaryKey": false,
21 + "notNull": true
22 + },
23 + "provider_id": {
24 + "name": "provider_id",
25 + "type": "text",
26 + "primaryKey": false,
27 + "notNull": true
28 + },
29 + "user_id": {
30 + "name": "user_id",
31 + "type": "text",
32 + "primaryKey": false,
33 + "notNull": true
34 + },
35 + "access_token": {
36 + "name": "access_token",
37 + "type": "text",
38 + "primaryKey": false,
39 + "notNull": false
40 + },
41 + "refresh_token": {
42 + "name": "refresh_token",
43 + "type": "text",
44 + "primaryKey": false,
45 + "notNull": false
46 + },
47 + "id_token": {
48 + "name": "id_token",
49 + "type": "text",
50 + "primaryKey": false,
51 + "notNull": false
52 + },
53 + "access_token_expires_at": {
54 + "name": "access_token_expires_at",
55 + "type": "timestamp with time zone",
56 + "primaryKey": false,
57 + "notNull": false
58 + },
59 + "refresh_token_expires_at": {
60 + "name": "refresh_token_expires_at",
61 + "type": "timestamp with time zone",
62 + "primaryKey": false,
63 + "notNull": false
64 + },
65 + "scope": {
66 + "name": "scope",
67 + "type": "text",
68 + "primaryKey": false,
69 + "notNull": false
70 + },
71 + "password": {
72 + "name": "password",
73 + "type": "text",
74 + "primaryKey": false,
75 + "notNull": false
76 + },
77 + "created_at": {
78 + "name": "created_at",
79 + "type": "timestamp with time zone",
80 + "primaryKey": false,
81 + "notNull": true,
82 + "default": "now()"
83 + },
84 + "updated_at": {
85 + "name": "updated_at",
86 + "type": "timestamp with time zone",
87 + "primaryKey": false,
88 + "notNull": true,
89 + "default": "now()"
90 + }
91 + },
92 + "indexes": {
93 + "accounts_user_idx": {
94 + "name": "accounts_user_idx",
95 + "columns": [
96 + {
97 + "expression": "user_id",
98 + "isExpression": false,
99 + "asc": true,
100 + "nulls": "last"
101 + }
102 + ],
103 + "isUnique": false,
104 + "concurrently": false,
105 + "method": "btree",
106 + "with": {}
107 + }
108 + },
109 + "foreignKeys": {
110 + "accounts_user_id_users_id_fk": {
111 + "name": "accounts_user_id_users_id_fk",
112 + "tableFrom": "accounts",
113 + "tableTo": "users",
114 + "columnsFrom": [
115 + "user_id"
116 + ],
117 + "columnsTo": [
118 + "id"
119 + ],
120 + "onDelete": "cascade",
121 + "onUpdate": "no action"
122 + }
123 + },
124 + "compositePrimaryKeys": {},
125 + "uniqueConstraints": {},
126 + "policies": {},
127 + "checkConstraints": {},
128 + "isRLSEnabled": false
129 + },
130 + "public.arena_responses": {
131 + "name": "arena_responses",
132 + "schema": "",
133 + "columns": {
134 + "id": {
135 + "name": "id",
136 + "type": "text",
137 + "primaryKey": true,
138 + "notNull": true
139 + },
140 + "session_id": {
141 + "name": "session_id",
142 + "type": "text",
143 + "primaryKey": false,
144 + "notNull": true
145 + },
146 + "model_key": {
147 + "name": "model_key",
148 + "type": "text",
149 + "primaryKey": false,
150 + "notNull": true
151 + },
152 + "provider": {
153 + "name": "provider",
154 + "type": "text",
155 + "primaryKey": false,
156 + "notNull": true
157 + },
158 + "content": {
159 + "name": "content",
160 + "type": "text",
161 + "primaryKey": false,
162 + "notNull": true,
163 + "default": "''"
164 + },
165 + "reasoning": {
166 + "name": "reasoning",
167 + "type": "text",
168 + "primaryKey": false,
169 + "notNull": false
170 + },
171 + "status": {
172 + "name": "status",
173 + "type": "text",
174 + "primaryKey": false,
175 + "notNull": true,
176 + "default": "'complete'"
177 + },
178 + "error": {
179 + "name": "error",
180 + "type": "jsonb",
181 + "primaryKey": false,
182 + "notNull": false
183 + },
184 + "usage": {
185 + "name": "usage",
186 + "type": "jsonb",
187 + "primaryKey": false,
188 + "notNull": false
189 + },
190 + "latency_ms": {
191 + "name": "latency_ms",
192 + "type": "integer",
193 + "primaryKey": false,
194 + "notNull": false
195 + },
196 + "ttft_ms": {
197 + "name": "ttft_ms",
198 + "type": "integer",
199 + "primaryKey": false,
200 + "notNull": false
201 + },
202 + "cost_usd": {
203 + "name": "cost_usd",
204 + "type": "double precision",
205 + "primaryKey": false,
206 + "notNull": false
207 + },
208 + "ratings": {
209 + "name": "ratings",
210 + "type": "jsonb",
211 + "primaryKey": false,
212 + "notNull": true,
213 + "default": "'{}'::jsonb"
214 + },
215 + "created_at": {
216 + "name": "created_at",
217 + "type": "timestamp with time zone",
218 + "primaryKey": false,
219 + "notNull": true,
220 + "default": "now()"
221 + }
222 + },
223 + "indexes": {
224 + "arena_responses_session_idx": {
225 + "name": "arena_responses_session_idx",
226 + "columns": [
227 + {
228 + "expression": "session_id",
229 + "isExpression": false,
230 + "asc": true,
231 + "nulls": "last"
232 + }
233 + ],
234 + "isUnique": false,
235 + "concurrently": false,
236 + "method": "btree",
237 + "with": {}
238 + }
239 + },
240 + "foreignKeys": {
241 + "arena_responses_session_id_arena_sessions_id_fk": {
242 + "name": "arena_responses_session_id_arena_sessions_id_fk",
243 + "tableFrom": "arena_responses",
244 + "tableTo": "arena_sessions",
245 + "columnsFrom": [
246 + "session_id"
247 + ],
248 + "columnsTo": [
249 + "id"
250 + ],
251 + "onDelete": "cascade",
252 + "onUpdate": "no action"
253 + }
254 + },
255 + "compositePrimaryKeys": {},
256 + "uniqueConstraints": {},
257 + "policies": {},
258 + "checkConstraints": {},
259 + "isRLSEnabled": false
260 + },
261 + "public.arena_sessions": {
262 + "name": "arena_sessions",
263 + "schema": "",
264 + "columns": {
265 + "id": {
266 + "name": "id",
267 + "type": "text",
268 + "primaryKey": true,
269 + "notNull": true
270 + },
271 + "user_id": {
272 + "name": "user_id",
273 + "type": "text",
274 + "primaryKey": false,
275 + "notNull": true
276 + },
277 + "prompt": {
278 + "name": "prompt",
279 + "type": "text",
280 + "primaryKey": false,
281 + "notNull": true
282 + },
283 + "system_prompt": {
284 + "name": "system_prompt",
285 + "type": "text",
286 + "primaryKey": false,
287 + "notNull": false
288 + },
289 + "model_keys": {
290 + "name": "model_keys",
291 + "type": "jsonb",
292 + "primaryKey": false,
293 + "notNull": true
294 + },
295 + "settings": {
296 + "name": "settings",
297 + "type": "jsonb",
298 + "primaryKey": false,
299 + "notNull": true,
300 + "default": "'{}'::jsonb"
301 + },
302 + "created_at": {
303 + "name": "created_at",
304 + "type": "timestamp with time zone",
305 + "primaryKey": false,
306 + "notNull": true,
307 + "default": "now()"
308 + }
309 + },
310 + "indexes": {
311 + "arena_sessions_user_idx": {
312 + "name": "arena_sessions_user_idx",
313 + "columns": [
314 + {
315 + "expression": "user_id",
316 + "isExpression": false,
317 + "asc": true,
318 + "nulls": "last"
319 + },
320 + {
321 + "expression": "created_at",
322 + "isExpression": false,
323 + "asc": true,
324 + "nulls": "last"
325 + }
326 + ],
327 + "isUnique": false,
328 + "concurrently": false,
329 + "method": "btree",
330 + "with": {}
331 + }
332 + },
333 + "foreignKeys": {
334 + "arena_sessions_user_id_users_id_fk": {
335 + "name": "arena_sessions_user_id_users_id_fk",
336 + "tableFrom": "arena_sessions",
337 + "tableTo": "users",
338 + "columnsFrom": [
339 + "user_id"
340 + ],
341 + "columnsTo": [
342 + "id"
343 + ],
344 + "onDelete": "cascade",
345 + "onUpdate": "no action"
346 + }
347 + },
348 + "compositePrimaryKeys": {},
349 + "uniqueConstraints": {},
350 + "policies": {},
351 + "checkConstraints": {},
352 + "isRLSEnabled": false
353 + },
354 + "public.audit_events": {
355 + "name": "audit_events",
356 + "schema": "",
357 + "columns": {
358 + "id": {
359 + "name": "id",
360 + "type": "text",
361 + "primaryKey": true,
362 + "notNull": true
363 + },
364 + "user_id": {
365 + "name": "user_id",
366 + "type": "text",
367 + "primaryKey": false,
368 + "notNull": false
369 + },
370 + "action": {
371 + "name": "action",
372 + "type": "text",
373 + "primaryKey": false,
374 + "notNull": true
375 + },
376 + "ip_address": {
377 + "name": "ip_address",
378 + "type": "text",
379 + "primaryKey": false,
380 + "notNull": false
381 + },
382 + "user_agent": {
383 + "name": "user_agent",
384 + "type": "text",
385 + "primaryKey": false,
386 + "notNull": false
387 + },
388 + "meta": {
389 + "name": "meta",
390 + "type": "jsonb",
391 + "primaryKey": false,
392 + "notNull": true,
393 + "default": "'{}'::jsonb"
394 + },
395 + "created_at": {
396 + "name": "created_at",
397 + "type": "timestamp with time zone",
398 + "primaryKey": false,
399 + "notNull": true,
400 + "default": "now()"
401 + }
402 + },
403 + "indexes": {
404 + "audit_user_created_idx": {
405 + "name": "audit_user_created_idx",
406 + "columns": [
407 + {
408 + "expression": "user_id",
409 + "isExpression": false,
410 + "asc": true,
411 + "nulls": "last"
412 + },
413 + {
414 + "expression": "created_at",
415 + "isExpression": false,
416 + "asc": true,
417 + "nulls": "last"
418 + }
419 + ],
420 + "isUnique": false,
421 + "concurrently": false,
422 + "method": "btree",
423 + "with": {}
424 + }
425 + },
426 + "foreignKeys": {
427 + "audit_events_user_id_users_id_fk": {
428 + "name": "audit_events_user_id_users_id_fk",
429 + "tableFrom": "audit_events",
430 + "tableTo": "users",
431 + "columnsFrom": [
432 + "user_id"
433 + ],
434 + "columnsTo": [
435 + "id"
436 + ],
437 + "onDelete": "set null",
438 + "onUpdate": "no action"
439 + }
440 + },
441 + "compositePrimaryKeys": {},
442 + "uniqueConstraints": {},
443 + "policies": {},
444 + "checkConstraints": {},
445 + "isRLSEnabled": false
446 + },
447 + "public.conversation_tags": {
448 + "name": "conversation_tags",
449 + "schema": "",
450 + "columns": {
451 + "conversation_id": {
452 + "name": "conversation_id",
453 + "type": "text",
454 + "primaryKey": false,
455 + "notNull": true
456 + },
457 + "tag_id": {
458 + "name": "tag_id",
459 + "type": "text",
460 + "primaryKey": false,
461 + "notNull": true
462 + }
463 + },
464 + "indexes": {},
465 + "foreignKeys": {
466 + "conversation_tags_conversation_id_conversations_id_fk": {
467 + "name": "conversation_tags_conversation_id_conversations_id_fk",
468 + "tableFrom": "conversation_tags",
469 + "tableTo": "conversations",
470 + "columnsFrom": [
471 + "conversation_id"
472 + ],
473 + "columnsTo": [
474 + "id"
475 + ],
476 + "onDelete": "cascade",
477 + "onUpdate": "no action"
478 + },
479 + "conversation_tags_tag_id_tags_id_fk": {
480 + "name": "conversation_tags_tag_id_tags_id_fk",
481 + "tableFrom": "conversation_tags",
482 + "tableTo": "tags",
483 + "columnsFrom": [
484 + "tag_id"
485 + ],
486 + "columnsTo": [
487 + "id"
488 + ],
489 + "onDelete": "cascade",
490 + "onUpdate": "no action"
491 + }
492 + },
493 + "compositePrimaryKeys": {
494 + "conversation_tags_conversation_id_tag_id_pk": {
495 + "name": "conversation_tags_conversation_id_tag_id_pk",
496 + "columns": [
497 + "conversation_id",
498 + "tag_id"
499 + ]
500 + }
501 + },
502 + "uniqueConstraints": {},
503 + "policies": {},
504 + "checkConstraints": {},
505 + "isRLSEnabled": false
506 + },
507 + "public.conversations": {
508 + "name": "conversations",
509 + "schema": "",
510 + "columns": {
511 + "id": {
512 + "name": "id",
513 + "type": "text",
514 + "primaryKey": true,
515 + "notNull": true
516 + },
517 + "user_id": {
518 + "name": "user_id",
519 + "type": "text",
520 + "primaryKey": false,
521 + "notNull": true
522 + },
523 + "title": {
524 + "name": "title",
525 + "type": "text",
526 + "primaryKey": false,
527 + "notNull": true,
528 + "default": "'New chat'"
529 + },
530 + "title_source": {
531 + "name": "title_source",
532 + "type": "text",
533 + "primaryKey": false,
534 + "notNull": true,
535 + "default": "'auto'"
536 + },
537 + "folder_id": {
538 + "name": "folder_id",
539 + "type": "text",
540 + "primaryKey": false,
541 + "notNull": false
542 + },
543 + "project_id": {
544 + "name": "project_id",
545 + "type": "text",
546 + "primaryKey": false,
547 + "notNull": false
548 + },
549 + "pinned": {
550 + "name": "pinned",
551 + "type": "boolean",
552 + "primaryKey": false,
553 + "notNull": true,
554 + "default": false
555 + },
556 + "archived": {
557 + "name": "archived",
558 + "type": "boolean",
559 + "primaryKey": false,
560 + "notNull": true,
561 + "default": false
562 + },
563 + "model_key": {
564 + "name": "model_key",
565 + "type": "text",
566 + "primaryKey": false,
567 + "notNull": false
568 + },
569 + "provider": {
570 + "name": "provider",
571 + "type": "text",
572 + "primaryKey": false,
573 + "notNull": false
574 + },
575 + "system_prompt": {
576 + "name": "system_prompt",
577 + "type": "text",
578 + "primaryKey": false,
579 + "notNull": false
580 + },
581 + "settings": {
582 + "name": "settings",
583 + "type": "jsonb",
584 + "primaryKey": false,
585 + "notNull": true,
586 + "default": "'{}'::jsonb"
587 + },
588 + "parent_conversation_id": {
589 + "name": "parent_conversation_id",
590 + "type": "text",
591 + "primaryKey": false,
592 + "notNull": false
593 + },
594 + "branched_from_message_id": {
595 + "name": "branched_from_message_id",
596 + "type": "text",
597 + "primaryKey": false,
598 + "notNull": false
599 + },
600 + "message_count": {
601 + "name": "message_count",
602 + "type": "integer",
603 + "primaryKey": false,
604 + "notNull": true,
605 + "default": 0
606 + },
607 + "total_cost_usd": {
608 + "name": "total_cost_usd",
609 + "type": "double precision",
610 + "primaryKey": false,
611 + "notNull": true,
612 + "default": 0
613 + },
614 + "total_input_tokens": {
615 + "name": "total_input_tokens",
616 + "type": "integer",
617 + "primaryKey": false,
618 + "notNull": true,
619 + "default": 0
620 + },
621 + "total_output_tokens": {
622 + "name": "total_output_tokens",
623 + "type": "integer",
624 + "primaryKey": false,
625 + "notNull": true,
626 + "default": 0
627 + },
628 + "last_message_at": {
629 + "name": "last_message_at",
630 + "type": "timestamp with time zone",
631 + "primaryKey": false,
632 + "notNull": false
633 + },
634 + "created_at": {
635 + "name": "created_at",
636 + "type": "timestamp with time zone",
637 + "primaryKey": false,
638 + "notNull": true,
639 + "default": "now()"
640 + },
641 + "updated_at": {
642 + "name": "updated_at",
643 + "type": "timestamp with time zone",
644 + "primaryKey": false,
645 + "notNull": true,
646 + "default": "now()"
647 + }
648 + },
649 + "indexes": {
650 + "conversations_user_updated_idx": {
651 + "name": "conversations_user_updated_idx",
652 + "columns": [
653 + {
654 + "expression": "user_id",
655 + "isExpression": false,
656 + "asc": true,
657 + "nulls": "last"
658 + },
659 + {
660 + "expression": "updated_at",
661 + "isExpression": false,
662 + "asc": true,
663 + "nulls": "last"
664 + }
665 + ],
666 + "isUnique": false,
667 + "concurrently": false,
668 + "method": "btree",
669 + "with": {}
670 + },
671 + "conversations_user_folder_idx": {
672 + "name": "conversations_user_folder_idx",
673 + "columns": [
674 + {
675 + "expression": "user_id",
676 + "isExpression": false,
677 + "asc": true,
678 + "nulls": "last"
679 + },
680 + {
681 + "expression": "folder_id",
682 + "isExpression": false,
683 + "asc": true,
684 + "nulls": "last"
685 + }
686 + ],
687 + "isUnique": false,
688 + "concurrently": false,
689 + "method": "btree",
690 + "with": {}
691 + },
692 + "conversations_user_model_idx": {
693 + "name": "conversations_user_model_idx",
694 + "columns": [
695 + {
696 + "expression": "user_id",
697 + "isExpression": false,
698 + "asc": true,
699 + "nulls": "last"
700 + },
701 + {
702 + "expression": "model_key",
703 + "isExpression": false,
704 + "asc": true,
705 + "nulls": "last"
706 + }
707 + ],
708 + "isUnique": false,
709 + "concurrently": false,
710 + "method": "btree",
711 + "with": {}
712 + },
713 + "conversations_user_project_idx": {
714 + "name": "conversations_user_project_idx",
715 + "columns": [
716 + {
717 + "expression": "user_id",
718 + "isExpression": false,
719 + "asc": true,
720 + "nulls": "last"
721 + },
722 + {
723 + "expression": "project_id",
724 + "isExpression": false,
725 + "asc": true,
726 + "nulls": "last"
727 + }
728 + ],
729 + "isUnique": false,
730 + "concurrently": false,
731 + "method": "btree",
732 + "with": {}
733 + }
734 + },
735 + "foreignKeys": {
736 + "conversations_user_id_users_id_fk": {
737 + "name": "conversations_user_id_users_id_fk",
738 + "tableFrom": "conversations",
739 + "tableTo": "users",
740 + "columnsFrom": [
741 + "user_id"
742 + ],
743 + "columnsTo": [
744 + "id"
745 + ],
746 + "onDelete": "cascade",
747 + "onUpdate": "no action"
748 + },
749 + "conversations_folder_id_folders_id_fk": {
750 + "name": "conversations_folder_id_folders_id_fk",
751 + "tableFrom": "conversations",
752 + "tableTo": "folders",
753 + "columnsFrom": [
754 + "folder_id"
755 + ],
756 + "columnsTo": [
757 + "id"
758 + ],
759 + "onDelete": "set null",
760 + "onUpdate": "no action"
761 + }
762 + },
763 + "compositePrimaryKeys": {},
764 + "uniqueConstraints": {},
765 + "policies": {},
766 + "checkConstraints": {},
767 + "isRLSEnabled": false
768 + },
769 + "public.folders": {
770 + "name": "folders",
771 + "schema": "",
772 + "columns": {
773 + "id": {
774 + "name": "id",
775 + "type": "text",
776 + "primaryKey": true,
777 + "notNull": true
778 + },
779 + "user_id": {
780 + "name": "user_id",
781 + "type": "text",
782 + "primaryKey": false,
783 + "notNull": true
784 + },
785 + "name": {
786 + "name": "name",
787 + "type": "text",
788 + "primaryKey": false,
789 + "notNull": true
790 + },
791 + "color": {
792 + "name": "color",
793 + "type": "text",
794 + "primaryKey": false,
795 + "notNull": false
796 + },
797 + "sort_order": {
798 + "name": "sort_order",
799 + "type": "integer",
800 + "primaryKey": false,
801 + "notNull": true,
802 + "default": 0
803 + },
804 + "created_at": {
805 + "name": "created_at",
806 + "type": "timestamp with time zone",
807 + "primaryKey": false,
808 + "notNull": true,
809 + "default": "now()"
810 + }
811 + },
812 + "indexes": {
813 + "folders_user_idx": {
814 + "name": "folders_user_idx",
815 + "columns": [
816 + {
817 + "expression": "user_id",
818 + "isExpression": false,
819 + "asc": true,
820 + "nulls": "last"
821 + }
822 + ],
823 + "isUnique": false,
824 + "concurrently": false,
825 + "method": "btree",
826 + "with": {}
827 + }
828 + },
829 + "foreignKeys": {
830 + "folders_user_id_users_id_fk": {
831 + "name": "folders_user_id_users_id_fk",
832 + "tableFrom": "folders",
833 + "tableTo": "users",
834 + "columnsFrom": [
835 + "user_id"
836 + ],
837 + "columnsTo": [
838 + "id"
839 + ],
840 + "onDelete": "cascade",
841 + "onUpdate": "no action"
842 + }
843 + },
844 + "compositePrimaryKeys": {},
845 + "uniqueConstraints": {},
846 + "policies": {},
847 + "checkConstraints": {},
848 + "isRLSEnabled": false
849 + },
850 + "public.message_attachments": {
851 + "name": "message_attachments",
852 + "schema": "",
853 + "columns": {
854 + "id": {
855 + "name": "id",
856 + "type": "text",
857 + "primaryKey": true,
858 + "notNull": true
859 + },
860 + "user_id": {
861 + "name": "user_id",
862 + "type": "text",
863 + "primaryKey": false,
864 + "notNull": true
865 + },
866 + "message_id": {
867 + "name": "message_id",
868 + "type": "text",
869 + "primaryKey": false,
870 + "notNull": false
871 + },
872 + "conversation_id": {
873 + "name": "conversation_id",
874 + "type": "text",
875 + "primaryKey": false,
876 + "notNull": false
877 + },
878 + "kind": {
879 + "name": "kind",
880 + "type": "text",
881 + "primaryKey": false,
882 + "notNull": true
883 + },
884 + "name": {
885 + "name": "name",
886 + "type": "text",
887 + "primaryKey": false,
888 + "notNull": true
889 + },
890 + "mime_type": {
891 + "name": "mime_type",
892 + "type": "text",
893 + "primaryKey": false,
894 + "notNull": true
895 + },
896 + "size_bytes": {
897 + "name": "size_bytes",
898 + "type": "integer",
899 + "primaryKey": false,
900 + "notNull": true
901 + },
902 + "data_base64": {
903 + "name": "data_base64",
904 + "type": "text",
905 + "primaryKey": false,
906 + "notNull": true
907 + },
908 + "width": {
909 + "name": "width",
910 + "type": "integer",
911 + "primaryKey": false,
912 + "notNull": false
913 + },
914 + "height": {
915 + "name": "height",
916 + "type": "integer",
917 + "primaryKey": false,
918 + "notNull": false
919 + },
920 + "created_at": {
921 + "name": "created_at",
922 + "type": "timestamp with time zone",
923 + "primaryKey": false,
924 + "notNull": true,
925 + "default": "now()"
926 + }
927 + },
928 + "indexes": {
929 + "attachments_message_idx": {
930 + "name": "attachments_message_idx",
931 + "columns": [
932 + {
933 + "expression": "message_id",
934 + "isExpression": false,
935 + "asc": true,
936 + "nulls": "last"
937 + }
938 + ],
939 + "isUnique": false,
940 + "concurrently": false,
941 + "method": "btree",
942 + "with": {}
943 + },
944 + "attachments_user_idx": {
945 + "name": "attachments_user_idx",
946 + "columns": [
947 + {
948 + "expression": "user_id",
949 + "isExpression": false,
950 + "asc": true,
951 + "nulls": "last"
952 + }
953 + ],
954 + "isUnique": false,
955 + "concurrently": false,
956 + "method": "btree",
957 + "with": {}
958 + }
959 + },
960 + "foreignKeys": {
961 + "message_attachments_user_id_users_id_fk": {
962 + "name": "message_attachments_user_id_users_id_fk",
963 + "tableFrom": "message_attachments",
964 + "tableTo": "users",
965 + "columnsFrom": [
966 + "user_id"
967 + ],
968 + "columnsTo": [
969 + "id"
970 + ],
971 + "onDelete": "cascade",
972 + "onUpdate": "no action"
973 + },
974 + "message_attachments_message_id_messages_id_fk": {
975 + "name": "message_attachments_message_id_messages_id_fk",
976 + "tableFrom": "message_attachments",
977 + "tableTo": "messages",
978 + "columnsFrom": [
979 + "message_id"
980 + ],
981 + "columnsTo": [
982 + "id"
983 + ],
984 + "onDelete": "cascade",
985 + "onUpdate": "no action"
986 + },
987 + "message_attachments_conversation_id_conversations_id_fk": {
988 + "name": "message_attachments_conversation_id_conversations_id_fk",
989 + "tableFrom": "message_attachments",
990 + "tableTo": "conversations",
991 + "columnsFrom": [
992 + "conversation_id"
993 + ],
994 + "columnsTo": [
995 + "id"
996 + ],
997 + "onDelete": "cascade",
998 + "onUpdate": "no action"
999 + }
1000 + },
1001 + "compositePrimaryKeys": {},
1002 + "uniqueConstraints": {},
1003 + "policies": {},
1004 + "checkConstraints": {},
1005 + "isRLSEnabled": false
1006 + },
1007 + "public.messages": {
1008 + "name": "messages",
1009 + "schema": "",
1010 + "columns": {
1011 + "id": {
1012 + "name": "id",
1013 + "type": "text",
1014 + "primaryKey": true,
1015 + "notNull": true
1016 + },
1017 + "conversation_id": {
1018 + "name": "conversation_id",
1019 + "type": "text",
1020 + "primaryKey": false,
1021 + "notNull": true
1022 + },
1023 + "user_id": {
1024 + "name": "user_id",
1025 + "type": "text",
1026 + "primaryKey": false,
1027 + "notNull": true
1028 + },
1029 + "role": {
1030 + "name": "role",
1031 + "type": "text",
1032 + "primaryKey": false,
1033 + "notNull": true
1034 + },
1035 + "content": {
1036 + "name": "content",
1037 + "type": "text",
1038 + "primaryKey": false,
1039 + "notNull": true,
1040 + "default": "''"
1041 + },
1042 + "parts": {
1043 + "name": "parts",
1044 + "type": "jsonb",
1045 + "primaryKey": false,
1046 + "notNull": true,
1047 + "default": "'[]'::jsonb"
1048 + },
1049 + "model_key": {
1050 + "name": "model_key",
1051 + "type": "text",
1052 + "primaryKey": false,
1053 + "notNull": false
1054 + },
1055 + "provider": {
1056 + "name": "provider",
1057 + "type": "text",
1058 + "primaryKey": false,
1059 + "notNull": false
1060 + },
1061 + "status": {
1062 + "name": "status",
1063 + "type": "text",
1064 + "primaryKey": false,
1065 + "notNull": true,
1066 + "default": "'complete'"
1067 + },
1068 + "finish_reason": {
1069 + "name": "finish_reason",
1070 + "type": "text",
1071 + "primaryKey": false,
1072 + "notNull": false
1073 + },
1074 + "error": {
1075 + "name": "error",
1076 + "type": "jsonb",
1077 + "primaryKey": false,
1078 + "notNull": false
1079 + },
1080 + "usage": {
1081 + "name": "usage",
1082 + "type": "jsonb",
1083 + "primaryKey": false,
1084 + "notNull": false
1085 + },
1086 + "settings": {
1087 + "name": "settings",
1088 + "type": "jsonb",
1089 + "primaryKey": false,
1090 + "notNull": false
1091 + },
1092 + "latency_ms": {
1093 + "name": "latency_ms",
1094 + "type": "integer",
1095 + "primaryKey": false,
1096 + "notNull": false
1097 + },
1098 + "ttft_ms": {
1099 + "name": "ttft_ms",
1100 + "type": "integer",
1101 + "primaryKey": false,
1102 + "notNull": false
1103 + },
1104 + "cost_usd": {
1105 + "name": "cost_usd",
1106 + "type": "double precision",
1107 + "primaryKey": false,
1108 + "notNull": false
1109 + },
1110 + "parent_message_id": {
1111 + "name": "parent_message_id",
1112 + "type": "text",
1113 + "primaryKey": false,
1114 + "notNull": false
1115 + },
1116 + "version": {
1117 + "name": "version",
1118 + "type": "integer",
1119 + "primaryKey": false,
1120 + "notNull": true,
1121 + "default": 1
1122 + },
1123 + "active": {
1124 + "name": "active",
1125 + "type": "boolean",
1126 + "primaryKey": false,
1127 + "notNull": true,
1128 + "default": true
1129 + },
1130 + "created_at": {
1131 + "name": "created_at",
1132 + "type": "timestamp with time zone",
1133 + "primaryKey": false,
1134 + "notNull": true,
1135 + "default": "now()"
1136 + },
1137 + "updated_at": {
1138 + "name": "updated_at",
1139 + "type": "timestamp with time zone",
1140 + "primaryKey": false,
1141 + "notNull": true,
1142 + "default": "now()"
1143 + }
1144 + },
1145 + "indexes": {
1146 + "messages_conversation_created_idx": {
1147 + "name": "messages_conversation_created_idx",
1148 + "columns": [
1149 + {
1150 + "expression": "conversation_id",
1151 + "isExpression": false,
1152 + "asc": true,
1153 + "nulls": "last"
1154 + },
1155 + {
1156 + "expression": "created_at",
1157 + "isExpression": false,
1158 + "asc": true,
1159 + "nulls": "last"
1160 + }
1161 + ],
1162 + "isUnique": false,
1163 + "concurrently": false,
1164 + "method": "btree",
1165 + "with": {}
1166 + },
1167 + "messages_user_idx": {
1168 + "name": "messages_user_idx",
1169 + "columns": [
1170 + {
1171 + "expression": "user_id",
1172 + "isExpression": false,
1173 + "asc": true,
1174 + "nulls": "last"
1175 + }
1176 + ],
1177 + "isUnique": false,
1178 + "concurrently": false,
1179 + "method": "btree",
1180 + "with": {}
1181 + }
1182 + },
1183 + "foreignKeys": {
1184 + "messages_conversation_id_conversations_id_fk": {
1185 + "name": "messages_conversation_id_conversations_id_fk",
1186 + "tableFrom": "messages",
1187 + "tableTo": "conversations",
1188 + "columnsFrom": [
1189 + "conversation_id"
1190 + ],
1191 + "columnsTo": [
1192 + "id"
1193 + ],
1194 + "onDelete": "cascade",
1195 + "onUpdate": "no action"
1196 + },
1197 + "messages_user_id_users_id_fk": {
1198 + "name": "messages_user_id_users_id_fk",
1199 + "tableFrom": "messages",
1200 + "tableTo": "users",
1201 + "columnsFrom": [
1202 + "user_id"
1203 + ],
1204 + "columnsTo": [
1205 + "id"
1206 + ],
1207 + "onDelete": "cascade",
1208 + "onUpdate": "no action"
1209 + }
1210 + },
1211 + "compositePrimaryKeys": {},
1212 + "uniqueConstraints": {},
1213 + "policies": {},
1214 + "checkConstraints": {},
1215 + "isRLSEnabled": false
1216 + },
1217 + "public.model_presets": {
1218 + "name": "model_presets",
1219 + "schema": "",
1220 + "columns": {
1221 + "id": {
1222 + "name": "id",
1223 + "type": "text",
1224 + "primaryKey": true,
1225 + "notNull": true
1226 + },
1227 + "user_id": {
1228 + "name": "user_id",
1229 + "type": "text",
1230 + "primaryKey": false,
1231 + "notNull": true
1232 + },
1233 + "name": {
1234 + "name": "name",
1235 + "type": "text",
1236 + "primaryKey": false,
1237 + "notNull": true
1238 + },
1239 + "description": {
1240 + "name": "description",
1241 + "type": "text",
1242 + "primaryKey": false,
1243 + "notNull": false
1244 + },
1245 + "icon": {
1246 + "name": "icon",
1247 + "type": "text",
1248 + "primaryKey": false,
1249 + "notNull": false
1250 + },
1251 + "model_key": {
1252 + "name": "model_key",
1253 + "type": "text",
1254 + "primaryKey": false,
1255 + "notNull": true
1256 + },
1257 + "system_prompt": {
1258 + "name": "system_prompt",
1259 + "type": "text",
1260 + "primaryKey": false,
1261 + "notNull": false
1262 + },
1263 + "parameters": {
1264 + "name": "parameters",
1265 + "type": "jsonb",
1266 + "primaryKey": false,
1267 + "notNull": true,
1268 + "default": "'{}'::jsonb"
1269 + },
1270 + "tools": {
1271 + "name": "tools",
1272 + "type": "jsonb",
1273 + "primaryKey": false,
1274 + "notNull": true,
1275 + "default": "'{}'::jsonb"
1276 + },
1277 + "file_settings": {
1278 + "name": "file_settings",
1279 + "type": "jsonb",
1280 + "primaryKey": false,
1281 + "notNull": true,
1282 + "default": "'{}'::jsonb"
1283 + },
1284 + "sort_order": {
1285 + "name": "sort_order",
1286 + "type": "integer",
1287 + "primaryKey": false,
1288 + "notNull": true,
1289 + "default": 0
1290 + },
1291 + "created_at": {
1292 + "name": "created_at",
1293 + "type": "timestamp with time zone",
1294 + "primaryKey": false,
1295 + "notNull": true,
1296 + "default": "now()"
1297 + },
1298 + "updated_at": {
1299 + "name": "updated_at",
1300 + "type": "timestamp with time zone",
1301 + "primaryKey": false,
1302 + "notNull": true,
1303 + "default": "now()"
1304 + }
1305 + },
1306 + "indexes": {
1307 + "model_presets_user_idx": {
1308 + "name": "model_presets_user_idx",
1309 + "columns": [
1310 + {
1311 + "expression": "user_id",
1312 + "isExpression": false,
1313 + "asc": true,
1314 + "nulls": "last"
1315 + }
1316 + ],
1317 + "isUnique": false,
1318 + "concurrently": false,
1319 + "method": "btree",
1320 + "with": {}
1321 + }
1322 + },
1323 + "foreignKeys": {
1324 + "model_presets_user_id_users_id_fk": {
1325 + "name": "model_presets_user_id_users_id_fk",
1326 + "tableFrom": "model_presets",
1327 + "tableTo": "users",
1328 + "columnsFrom": [
1329 + "user_id"
1330 + ],
1331 + "columnsTo": [
1332 + "id"
1333 + ],
1334 + "onDelete": "cascade",
1335 + "onUpdate": "no action"
1336 + }
1337 + },
1338 + "compositePrimaryKeys": {},
1339 + "uniqueConstraints": {},
1340 + "policies": {},
1341 + "checkConstraints": {},
1342 + "isRLSEnabled": false
1343 + },
1344 + "public.model_sync_runs": {
1345 + "name": "model_sync_runs",
1346 + "schema": "",
1347 + "columns": {
1348 + "id": {
1349 + "name": "id",
1350 + "type": "text",
1351 + "primaryKey": true,
1352 + "notNull": true
1353 + },
1354 + "provider": {
1355 + "name": "provider",
1356 + "type": "text",
1357 + "primaryKey": false,
1358 + "notNull": true
1359 + },
1360 + "triggered_by": {
1361 + "name": "triggered_by",
1362 + "type": "text",
1363 + "primaryKey": false,
1364 + "notNull": true,
1365 + "default": "'schedule'"
1366 + },
1367 + "started_at": {
1368 + "name": "started_at",
1369 + "type": "timestamp with time zone",
1370 + "primaryKey": false,
1371 + "notNull": true,
1372 + "default": "now()"
1373 + },
1374 + "finished_at": {
1375 + "name": "finished_at",
1376 + "type": "timestamp with time zone",
1377 + "primaryKey": false,
1378 + "notNull": false
1379 + },
1380 + "ok": {
1381 + "name": "ok",
1382 + "type": "boolean",
1383 + "primaryKey": false,
1384 + "notNull": false
1385 + },
1386 + "models_found": {
1387 + "name": "models_found",
1388 + "type": "integer",
1389 + "primaryKey": false,
1390 + "notNull": false
1391 + },
1392 + "models_added": {
1393 + "name": "models_added",
1394 + "type": "integer",
1395 + "primaryKey": false,
1396 + "notNull": false
1397 + },
1398 + "models_removed": {
1399 + "name": "models_removed",
1400 + "type": "integer",
1401 + "primaryKey": false,
1402 + "notNull": false
1403 + },
1404 + "latency_ms": {
1405 + "name": "latency_ms",
1406 + "type": "integer",
1407 + "primaryKey": false,
1408 + "notNull": false
1409 + },
1410 + "error_code": {
1411 + "name": "error_code",
1412 + "type": "text",
1413 + "primaryKey": false,
1414 + "notNull": false
1415 + },
1416 + "error_message": {
1417 + "name": "error_message",
1418 + "type": "text",
1419 + "primaryKey": false,
1420 + "notNull": false
1421 + }
1422 + },
1423 + "indexes": {
1424 + "model_sync_runs_provider_started_idx": {
1425 + "name": "model_sync_runs_provider_started_idx",
1426 + "columns": [
1427 + {
1428 + "expression": "provider",
1429 + "isExpression": false,
1430 + "asc": true,
1431 + "nulls": "last"
1432 + },
1433 + {
1434 + "expression": "started_at",
1435 + "isExpression": false,
1436 + "asc": true,
1437 + "nulls": "last"
1438 + }
1439 + ],
1440 + "isUnique": false,
1441 + "concurrently": false,
1442 + "method": "btree",
1443 + "with": {}
1444 + }
1445 + },
1446 + "foreignKeys": {},
1447 + "compositePrimaryKeys": {},
1448 + "uniqueConstraints": {},
1449 + "policies": {},
1450 + "checkConstraints": {},
1451 + "isRLSEnabled": false
1452 + },
1453 + "public.models": {
1454 + "name": "models",
1455 + "schema": "",
1456 + "columns": {
1457 + "key": {
1458 + "name": "key",
1459 + "type": "text",
1460 + "primaryKey": true,
1461 + "notNull": true
1462 + },
1463 + "provider": {
1464 + "name": "provider",
1465 + "type": "text",
1466 + "primaryKey": false,
1467 + "notNull": true
1468 + },
1469 + "model_id": {
1470 + "name": "model_id",
1471 + "type": "text",
1472 + "primaryKey": false,
1473 + "notNull": true
1474 + },
1475 + "display_name": {
1476 + "name": "display_name",
1477 + "type": "text",
1478 + "primaryKey": false,
1479 + "notNull": true
1480 + },
1481 + "family": {
1482 + "name": "family",
1483 + "type": "text",
1484 + "primaryKey": false,
1485 + "notNull": false
1486 + },
1487 + "capabilities": {
1488 + "name": "capabilities",
1489 + "type": "jsonb",
1490 + "primaryKey": false,
1491 + "notNull": true
1492 + },
1493 + "limits": {
1494 + "name": "limits",
1495 + "type": "jsonb",
1496 + "primaryKey": false,
1497 + "notNull": true,
1498 + "default": "'{}'::jsonb"
1499 + },
1500 + "parameters": {
1501 + "name": "parameters",
1502 + "type": "jsonb",
1503 + "primaryKey": false,
1504 + "notNull": true,
1505 + "default": "'{}'::jsonb"
1506 + },
1507 + "pricing": {
1508 + "name": "pricing",
1509 + "type": "jsonb",
1510 + "primaryKey": false,
1511 + "notNull": false
1512 + },
1513 + "status": {
1514 + "name": "status",
1515 + "type": "text",
1516 + "primaryKey": false,
1517 + "notNull": true,
1518 + "default": "'unknown'"
1519 + },
1520 + "source": {
1521 + "name": "source",
1522 + "type": "text",
1523 + "primaryKey": false,
1524 + "notNull": true,
1525 + "default": "'catalog'"
1526 + },
1527 + "hidden": {
1528 + "name": "hidden",
1529 + "type": "boolean",
1530 + "primaryKey": false,
1531 + "notNull": true,
1532 + "default": false
1533 + },
1534 + "sort_weight": {
1535 + "name": "sort_weight",
1536 + "type": "integer",
1537 + "primaryKey": false,
1538 + "notNull": true,
1539 + "default": 0
1540 + },
1541 + "metadata": {
1542 + "name": "metadata",
1543 + "type": "jsonb",
1544 + "primaryKey": false,
1545 + "notNull": true,
1546 + "default": "'{}'::jsonb"
1547 + },
1548 + "first_seen_at": {
1549 + "name": "first_seen_at",
1550 + "type": "timestamp with time zone",
1551 + "primaryKey": false,
1552 + "notNull": true,
1553 + "default": "now()"
1554 + },
1555 + "last_seen_at": {
1556 + "name": "last_seen_at",
1557 + "type": "timestamp with time zone",
1558 + "primaryKey": false,
1559 + "notNull": true,
1560 + "default": "now()"
1561 + },
1562 + "updated_at": {
1563 + "name": "updated_at",
1564 + "type": "timestamp with time zone",
1565 + "primaryKey": false,
1566 + "notNull": true,
1567 + "default": "now()"
1568 + }
1569 + },
1570 + "indexes": {
1571 + "models_provider_idx": {
1572 + "name": "models_provider_idx",
1573 + "columns": [
1574 + {
1575 + "expression": "provider",
1576 + "isExpression": false,
1577 + "asc": true,
1578 + "nulls": "last"
1579 + }
1580 + ],
1581 + "isUnique": false,
1582 + "concurrently": false,
1583 + "method": "btree",
1584 + "with": {}
1585 + },
1586 + "models_status_idx": {
1587 + "name": "models_status_idx",
1588 + "columns": [
1589 + {
1590 + "expression": "status",
1591 + "isExpression": false,
1592 + "asc": true,
1593 + "nulls": "last"
1594 + }
1595 + ],
1596 + "isUnique": false,
1597 + "concurrently": false,
1598 + "method": "btree",
1599 + "with": {}
1600 + }
1601 + },
1602 + "foreignKeys": {},
1603 + "compositePrimaryKeys": {},
1604 + "uniqueConstraints": {},
1605 + "policies": {},
1606 + "checkConstraints": {},
1607 + "isRLSEnabled": false
1608 + },
1609 + "public.prompt_presets": {
1610 + "name": "prompt_presets",
1611 + "schema": "",
1612 + "columns": {
1613 + "id": {
1614 + "name": "id",
1615 + "type": "text",
1616 + "primaryKey": true,
1617 + "notNull": true
1618 + },
1619 + "user_id": {
1620 + "name": "user_id",
1621 + "type": "text",
1622 + "primaryKey": false,
1623 + "notNull": true
1624 + },
1625 + "name": {
1626 + "name": "name",
1627 + "type": "text",
1628 + "primaryKey": false,
1629 + "notNull": true
1630 + },
1631 + "description": {
1632 + "name": "description",
1633 + "type": "text",
1634 + "primaryKey": false,
1635 + "notNull": false
1636 + },
1637 + "icon": {
1638 + "name": "icon",
1639 + "type": "text",
1640 + "primaryKey": false,
1641 + "notNull": false
1642 + },
1643 + "system_prompt": {
1644 + "name": "system_prompt",
1645 + "type": "text",
1646 + "primaryKey": false,
1647 + "notNull": true
1648 + },
1649 + "default_model_key": {
1650 + "name": "default_model_key",
1651 + "type": "text",
1652 + "primaryKey": false,
1653 + "notNull": false
1654 + },
1655 + "parameters": {
1656 + "name": "parameters",
1657 + "type": "jsonb",
1658 + "primaryKey": false,
1659 + "notNull": true,
1660 + "default": "'{}'::jsonb"
1661 + },
1662 + "tools": {
1663 + "name": "tools",
1664 + "type": "jsonb",
1665 + "primaryKey": false,
1666 + "notNull": true,
1667 + "default": "'{}'::jsonb"
1668 + },
1669 + "sort_order": {
1670 + "name": "sort_order",
1671 + "type": "integer",
1672 + "primaryKey": false,
1673 + "notNull": true,
1674 + "default": 0
1675 + },
1676 + "created_at": {
1677 + "name": "created_at",
1678 + "type": "timestamp with time zone",
1679 + "primaryKey": false,
1680 + "notNull": true,
1681 + "default": "now()"
1682 + },
1683 + "updated_at": {
1684 + "name": "updated_at",
1685 + "type": "timestamp with time zone",
1686 + "primaryKey": false,
1687 + "notNull": true,
1688 + "default": "now()"
1689 + }
1690 + },
1691 + "indexes": {
1692 + "prompt_presets_user_idx": {
1693 + "name": "prompt_presets_user_idx",
1694 + "columns": [
1695 + {
1696 + "expression": "user_id",
1697 + "isExpression": false,
1698 + "asc": true,
1699 + "nulls": "last"
1700 + }
1701 + ],
1702 + "isUnique": false,
1703 + "concurrently": false,
1704 + "method": "btree",
1705 + "with": {}
1706 + }
1707 + },
1708 + "foreignKeys": {
1709 + "prompt_presets_user_id_users_id_fk": {
1710 + "name": "prompt_presets_user_id_users_id_fk",
1711 + "tableFrom": "prompt_presets",
1712 + "tableTo": "users",
1713 + "columnsFrom": [
1714 + "user_id"
1715 + ],
1716 + "columnsTo": [
1717 + "id"
1718 + ],
1719 + "onDelete": "cascade",
1720 + "onUpdate": "no action"
1721 + }
1722 + },
1723 + "compositePrimaryKeys": {},
1724 + "uniqueConstraints": {},
1725 + "policies": {},
1726 + "checkConstraints": {},
1727 + "isRLSEnabled": false
1728 + },
1729 + "public.provider_connections": {
1730 + "name": "provider_connections",
1731 + "schema": "",
1732 + "columns": {
1733 + "id": {
1734 + "name": "id",
1735 + "type": "text",
1736 + "primaryKey": true,
1737 + "notNull": true
1738 + },
1739 + "user_id": {
1740 + "name": "user_id",
1741 + "type": "text",
1742 + "primaryKey": false,
1743 + "notNull": true
1744 + },
1745 + "provider": {
1746 + "name": "provider",
1747 + "type": "text",
1748 + "primaryKey": false,
1749 + "notNull": true
1750 + },
1751 + "encrypted_key": {
1752 + "name": "encrypted_key",
1753 + "type": "text",
1754 + "primaryKey": false,
1755 + "notNull": true
1756 + },
1757 + "key_hint": {
1758 + "name": "key_hint",
1759 + "type": "text",
1760 + "primaryKey": false,
1761 + "notNull": true
1762 + },
1763 + "key_fingerprint": {
1764 + "name": "key_fingerprint",
1765 + "type": "text",
1766 + "primaryKey": false,
1767 + "notNull": true
1768 + },
1769 + "status": {
1770 + "name": "status",
1771 + "type": "text",
1772 + "primaryKey": false,
1773 + "notNull": true,
1774 + "default": "'unverified'"
1775 + },
1776 + "last_validated_at": {
1777 + "name": "last_validated_at",
1778 + "type": "timestamp with time zone",
1779 + "primaryKey": false,
1780 + "notNull": false
1781 + },
1782 + "last_validation_error": {
1783 + "name": "last_validation_error",
1784 + "type": "text",
1785 + "primaryKey": false,
1786 + "notNull": false
1787 + },
1788 + "last_success_at": {
1789 + "name": "last_success_at",
1790 + "type": "timestamp with time zone",
1791 + "primaryKey": false,
1792 + "notNull": false
1793 + },
1794 + "last_error_at": {
1795 + "name": "last_error_at",
1796 + "type": "timestamp with time zone",
1797 + "primaryKey": false,
1798 + "notNull": false
1799 + },
1800 + "last_error_code": {
1801 + "name": "last_error_code",
1802 + "type": "text",
1803 + "primaryKey": false,
1804 + "notNull": false
1805 + },
1806 + "models_available": {
1807 + "name": "models_available",
1808 + "type": "integer",
1809 + "primaryKey": false,
1810 + "notNull": false
1811 + },
1812 + "created_at": {
1813 + "name": "created_at",
1814 + "type": "timestamp with time zone",
1815 + "primaryKey": false,
1816 + "notNull": true,
1817 + "default": "now()"
1818 + },
1819 + "updated_at": {
1820 + "name": "updated_at",
1821 + "type": "timestamp with time zone",
1822 + "primaryKey": false,
1823 + "notNull": true,
1824 + "default": "now()"
1825 + }
1826 + },
1827 + "indexes": {
1828 + "provider_connections_user_provider_uq": {
1829 + "name": "provider_connections_user_provider_uq",
1830 + "columns": [
1831 + {
1832 + "expression": "user_id",
1833 + "isExpression": false,
1834 + "asc": true,
1835 + "nulls": "last"
1836 + },
1837 + {
1838 + "expression": "provider",
1839 + "isExpression": false,
1840 + "asc": true,
1841 + "nulls": "last"
1842 + }
1843 + ],
1844 + "isUnique": true,
1845 + "concurrently": false,
1846 + "method": "btree",
1847 + "with": {}
1848 + }
1849 + },
1850 + "foreignKeys": {
1851 + "provider_connections_user_id_users_id_fk": {
1852 + "name": "provider_connections_user_id_users_id_fk",
1853 + "tableFrom": "provider_connections",
1854 + "tableTo": "users",
1855 + "columnsFrom": [
1856 + "user_id"
1857 + ],
1858 + "columnsTo": [
1859 + "id"
1860 + ],
1861 + "onDelete": "cascade",
1862 + "onUpdate": "no action"
1863 + }
1864 + },
1865 + "compositePrimaryKeys": {},
1866 + "uniqueConstraints": {},
1867 + "policies": {},
1868 + "checkConstraints": {},
1869 + "isRLSEnabled": false
1870 + },
1871 + "public.sessions": {
1872 + "name": "sessions",
1873 + "schema": "",
1874 + "columns": {
1875 + "id": {
1876 + "name": "id",
1877 + "type": "text",
1878 + "primaryKey": true,
1879 + "notNull": true
1880 + },
1881 + "expires_at": {
1882 + "name": "expires_at",
1883 + "type": "timestamp with time zone",
1884 + "primaryKey": false,
1885 + "notNull": true
1886 + },
1887 + "token": {
1888 + "name": "token",
1889 + "type": "text",
1890 + "primaryKey": false,
1891 + "notNull": true
1892 + },
1893 + "created_at": {
1894 + "name": "created_at",
1895 + "type": "timestamp with time zone",
1896 + "primaryKey": false,
1897 + "notNull": true,
1898 + "default": "now()"
1899 + },
1900 + "updated_at": {
1901 + "name": "updated_at",
1902 + "type": "timestamp with time zone",
1903 + "primaryKey": false,
1904 + "notNull": true,
1905 + "default": "now()"
1906 + },
1907 + "ip_address": {
1908 + "name": "ip_address",
1909 + "type": "text",
1910 + "primaryKey": false,
1911 + "notNull": false
1912 + },
1913 + "user_agent": {
1914 + "name": "user_agent",
1915 + "type": "text",
1916 + "primaryKey": false,
1917 + "notNull": false
1918 + },
1919 + "user_id": {
1920 + "name": "user_id",
1921 + "type": "text",
1922 + "primaryKey": false,
1923 + "notNull": true
1924 + }
1925 + },
1926 + "indexes": {
1927 + "sessions_token_uq": {
1928 + "name": "sessions_token_uq",
1929 + "columns": [
1930 + {
1931 + "expression": "token",
1932 + "isExpression": false,
1933 + "asc": true,
1934 + "nulls": "last"
1935 + }
1936 + ],
1937 + "isUnique": true,
1938 + "concurrently": false,
1939 + "method": "btree",
1940 + "with": {}
1941 + },
1942 + "sessions_user_idx": {
1943 + "name": "sessions_user_idx",
1944 + "columns": [
1945 + {
1946 + "expression": "user_id",
1947 + "isExpression": false,
1948 + "asc": true,
1949 + "nulls": "last"
1950 + }
1951 + ],
1952 + "isUnique": false,
1953 + "concurrently": false,
1954 + "method": "btree",
1955 + "with": {}
1956 + }
1957 + },
1958 + "foreignKeys": {
1959 + "sessions_user_id_users_id_fk": {
1960 + "name": "sessions_user_id_users_id_fk",
1961 + "tableFrom": "sessions",
1962 + "tableTo": "users",
1963 + "columnsFrom": [
1964 + "user_id"
1965 + ],
1966 + "columnsTo": [
1967 + "id"
1968 + ],
1969 + "onDelete": "cascade",
1970 + "onUpdate": "no action"
1971 + }
1972 + },
1973 + "compositePrimaryKeys": {},
1974 + "uniqueConstraints": {},
1975 + "policies": {},
1976 + "checkConstraints": {},
1977 + "isRLSEnabled": false
1978 + },
1979 + "public.shared_conversations": {
1980 + "name": "shared_conversations",
1981 + "schema": "",
1982 + "columns": {
1983 + "id": {
1984 + "name": "id",
1985 + "type": "text",
1986 + "primaryKey": true,
1987 + "notNull": true
1988 + },
1989 + "conversation_id": {
1990 + "name": "conversation_id",
1991 + "type": "text",
1992 + "primaryKey": false,
1993 + "notNull": true
1994 + },
1995 + "user_id": {
1996 + "name": "user_id",
1997 + "type": "text",
1998 + "primaryKey": false,
1999 + "notNull": true
2000 + },
2001 + "title": {
2002 + "name": "title",
2003 + "type": "text",
2004 + "primaryKey": false,
2005 + "notNull": true
2006 + },
2007 + "snapshot": {
2008 + "name": "snapshot",
2009 + "type": "jsonb",
2010 + "primaryKey": false,
2011 + "notNull": true
2012 + },
2013 + "is_public": {
2014 + "name": "is_public",
2015 + "type": "boolean",
2016 + "primaryKey": false,
2017 + "notNull": true,
2018 + "default": true
2019 + },
2020 + "view_count": {
2021 + "name": "view_count",
2022 + "type": "integer",
2023 + "primaryKey": false,
2024 + "notNull": true,
2025 + "default": 0
2026 + },
2027 + "created_at": {
2028 + "name": "created_at",
2029 + "type": "timestamp with time zone",
2030 + "primaryKey": false,
2031 + "notNull": true,
2032 + "default": "now()"
2033 + },
2034 + "revoked_at": {
2035 + "name": "revoked_at",
2036 + "type": "timestamp with time zone",
2037 + "primaryKey": false,
2038 + "notNull": false
2039 + }
2040 + },
2041 + "indexes": {
2042 + "shared_conversation_idx": {
2043 + "name": "shared_conversation_idx",
2044 + "columns": [
2045 + {
2046 + "expression": "conversation_id",
2047 + "isExpression": false,
2048 + "asc": true,
2049 + "nulls": "last"
2050 + }
2051 + ],
2052 + "isUnique": false,
2053 + "concurrently": false,
2054 + "method": "btree",
2055 + "with": {}
2056 + }
2057 + },
2058 + "foreignKeys": {
2059 + "shared_conversations_conversation_id_conversations_id_fk": {
2060 + "name": "shared_conversations_conversation_id_conversations_id_fk",
2061 + "tableFrom": "shared_conversations",
2062 + "tableTo": "conversations",
2063 + "columnsFrom": [
2064 + "conversation_id"
2065 + ],
2066 + "columnsTo": [
2067 + "id"
2068 + ],
2069 + "onDelete": "cascade",
2070 + "onUpdate": "no action"
2071 + },
2072 + "shared_conversations_user_id_users_id_fk": {
2073 + "name": "shared_conversations_user_id_users_id_fk",
2074 + "tableFrom": "shared_conversations",
2075 + "tableTo": "users",
2076 + "columnsFrom": [
2077 + "user_id"
2078 + ],
2079 + "columnsTo": [
2080 + "id"
2081 + ],
2082 + "onDelete": "cascade",
2083 + "onUpdate": "no action"
2084 + }
2085 + },
2086 + "compositePrimaryKeys": {},
2087 + "uniqueConstraints": {},
2088 + "policies": {},
2089 + "checkConstraints": {},
2090 + "isRLSEnabled": false
2091 + },
2092 + "public.tags": {
2093 + "name": "tags",
2094 + "schema": "",
2095 + "columns": {
2096 + "id": {
2097 + "name": "id",
2098 + "type": "text",
2099 + "primaryKey": true,
2100 + "notNull": true
2101 + },
2102 + "user_id": {
2103 + "name": "user_id",
2104 + "type": "text",
2105 + "primaryKey": false,
2106 + "notNull": true
2107 + },
2108 + "name": {
2109 + "name": "name",
2110 + "type": "text",
2111 + "primaryKey": false,
2112 + "notNull": true
2113 + },
2114 + "color": {
2115 + "name": "color",
2116 + "type": "text",
2117 + "primaryKey": false,
2118 + "notNull": false
2119 + },
2120 + "created_at": {
2121 + "name": "created_at",
2122 + "type": "timestamp with time zone",
2123 + "primaryKey": false,
2124 + "notNull": true,
2125 + "default": "now()"
2126 + }
2127 + },
2128 + "indexes": {
2129 + "tags_user_name_uq": {
2130 + "name": "tags_user_name_uq",
2131 + "columns": [
2132 + {
2133 + "expression": "user_id",
2134 + "isExpression": false,
2135 + "asc": true,
2136 + "nulls": "last"
2137 + },
2138 + {
2139 + "expression": "name",
2140 + "isExpression": false,
2141 + "asc": true,
2142 + "nulls": "last"
2143 + }
2144 + ],
2145 + "isUnique": true,
2146 + "concurrently": false,
2147 + "method": "btree",
2148 + "with": {}
2149 + }
2150 + },
2151 + "foreignKeys": {
2152 + "tags_user_id_users_id_fk": {
2153 + "name": "tags_user_id_users_id_fk",
2154 + "tableFrom": "tags",
2155 + "tableTo": "users",
2156 + "columnsFrom": [
2157 + "user_id"
2158 + ],
2159 + "columnsTo": [
2160 + "id"
2161 + ],
2162 + "onDelete": "cascade",
2163 + "onUpdate": "no action"
2164 + }
2165 + },
2166 + "compositePrimaryKeys": {},
2167 + "uniqueConstraints": {},
2168 + "policies": {},
2169 + "checkConstraints": {},
2170 + "isRLSEnabled": false
2171 + },
2172 + "public.usage_records": {
2173 + "name": "usage_records",
2174 + "schema": "",
2175 + "columns": {
2176 + "id": {
2177 + "name": "id",
2178 + "type": "text",
2179 + "primaryKey": true,
2180 + "notNull": true
2181 + },
2182 + "user_id": {
2183 + "name": "user_id",
2184 + "type": "text",
2185 + "primaryKey": false,
2186 + "notNull": true
2187 + },
2188 + "conversation_id": {
2189 + "name": "conversation_id",
2190 + "type": "text",
2191 + "primaryKey": false,
2192 + "notNull": false
2193 + },
2194 + "message_id": {
2195 + "name": "message_id",
2196 + "type": "text",
2197 + "primaryKey": false,
2198 + "notNull": false
2199 + },
2200 + "arena_session_id": {
2201 + "name": "arena_session_id",
2202 + "type": "text",
2203 + "primaryKey": false,
2204 + "notNull": false
2205 + },
2206 + "provider": {
2207 + "name": "provider",
2208 + "type": "text",
2209 + "primaryKey": false,
2210 + "notNull": true
2211 + },
2212 + "model_key": {
2213 + "name": "model_key",
2214 + "type": "text",
2215 + "primaryKey": false,
2216 + "notNull": true
2217 + },
2218 + "kind": {
2219 + "name": "kind",
2220 + "type": "text",
2221 + "primaryKey": false,
2222 + "notNull": true,
2223 + "default": "'chat'"
2224 + },
2225 + "status": {
2226 + "name": "status",
2227 + "type": "text",
2228 + "primaryKey": false,
2229 + "notNull": true,
2230 + "default": "'ok'"
2231 + },
2232 + "error_code": {
2233 + "name": "error_code",
2234 + "type": "text",
2235 + "primaryKey": false,
2236 + "notNull": false
2237 + },
2238 + "input_tokens": {
2239 + "name": "input_tokens",
2240 + "type": "integer",
2241 + "primaryKey": false,
2242 + "notNull": true,
2243 + "default": 0
2244 + },
2245 + "output_tokens": {
2246 + "name": "output_tokens",
2247 + "type": "integer",
2248 + "primaryKey": false,
2249 + "notNull": true,
2250 + "default": 0
2251 + },
2252 + "cached_tokens": {
2253 + "name": "cached_tokens",
2254 + "type": "integer",
2255 + "primaryKey": false,
2256 + "notNull": true,
2257 + "default": 0
2258 + },
2259 + "reasoning_tokens": {
2260 + "name": "reasoning_tokens",
2261 + "type": "integer",
2262 + "primaryKey": false,
2263 + "notNull": true,
2264 + "default": 0
2265 + },
2266 + "cost_usd": {
2267 + "name": "cost_usd",
2268 + "type": "double precision",
2269 + "primaryKey": false,
2270 + "notNull": false
2271 + },
2272 + "latency_ms": {
2273 + "name": "latency_ms",
2274 + "type": "integer",
2275 + "primaryKey": false,
2276 + "notNull": false
2277 + },
2278 + "ttft_ms": {
2279 + "name": "ttft_ms",
2280 + "type": "integer",
2281 + "primaryKey": false,
2282 + "notNull": false
2283 + },
2284 + "created_at": {
2285 + "name": "created_at",
2286 + "type": "timestamp with time zone",
2287 + "primaryKey": false,
2288 + "notNull": true,
2289 + "default": "now()"
2290 + }
2291 + },
2292 + "indexes": {
2293 + "usage_user_created_idx": {
2294 + "name": "usage_user_created_idx",
2295 + "columns": [
2296 + {
2297 + "expression": "user_id",
2298 + "isExpression": false,
2299 + "asc": true,
2300 + "nulls": "last"
2301 + },
2302 + {
2303 + "expression": "created_at",
2304 + "isExpression": false,
2305 + "asc": true,
2306 + "nulls": "last"
2307 + }
2308 + ],
2309 + "isUnique": false,
2310 + "concurrently": false,
2311 + "method": "btree",
2312 + "with": {}
2313 + },
2314 + "usage_user_provider_idx": {
2315 + "name": "usage_user_provider_idx",
2316 + "columns": [
2317 + {
2318 + "expression": "user_id",
2319 + "isExpression": false,
2320 + "asc": true,
2321 + "nulls": "last"
2322 + },
2323 + {
2324 + "expression": "provider",
2325 + "isExpression": false,
2326 + "asc": true,
2327 + "nulls": "last"
2328 + }
2329 + ],
2330 + "isUnique": false,
2331 + "concurrently": false,
2332 + "method": "btree",
2333 + "with": {}
2334 + },
2335 + "usage_user_model_idx": {
2336 + "name": "usage_user_model_idx",
2337 + "columns": [
2338 + {
2339 + "expression": "user_id",
2340 + "isExpression": false,
2341 + "asc": true,
2342 + "nulls": "last"
2343 + },
2344 + {
2345 + "expression": "model_key",
2346 + "isExpression": false,
2347 + "asc": true,
2348 + "nulls": "last"
2349 + }
2350 + ],
2351 + "isUnique": false,
2352 + "concurrently": false,
2353 + "method": "btree",
2354 + "with": {}
2355 + }
2356 + },
2357 + "foreignKeys": {
2358 + "usage_records_user_id_users_id_fk": {
2359 + "name": "usage_records_user_id_users_id_fk",
2360 + "tableFrom": "usage_records",
2361 + "tableTo": "users",
2362 + "columnsFrom": [
2363 + "user_id"
2364 + ],
2365 + "columnsTo": [
2366 + "id"
2367 + ],
2368 + "onDelete": "cascade",
2369 + "onUpdate": "no action"
2370 + }
2371 + },
2372 + "compositePrimaryKeys": {},
2373 + "uniqueConstraints": {},
2374 + "policies": {},
2375 + "checkConstraints": {},
2376 + "isRLSEnabled": false
2377 + },
2378 + "public.user_model_favorites": {
2379 + "name": "user_model_favorites",
2380 + "schema": "",
2381 + "columns": {
2382 + "user_id": {
2383 + "name": "user_id",
2384 + "type": "text",
2385 + "primaryKey": false,
2386 + "notNull": true
2387 + },
2388 + "model_key": {
2389 + "name": "model_key",
2390 + "type": "text",
2391 + "primaryKey": false,
2392 + "notNull": true
2393 + },
2394 + "created_at": {
2395 + "name": "created_at",
2396 + "type": "timestamp with time zone",
2397 + "primaryKey": false,
2398 + "notNull": true,
2399 + "default": "now()"
2400 + }
2401 + },
2402 + "indexes": {},
2403 + "foreignKeys": {
2404 + "user_model_favorites_user_id_users_id_fk": {
2405 + "name": "user_model_favorites_user_id_users_id_fk",
2406 + "tableFrom": "user_model_favorites",
2407 + "tableTo": "users",
2408 + "columnsFrom": [
2409 + "user_id"
2410 + ],
2411 + "columnsTo": [
2412 + "id"
2413 + ],
2414 + "onDelete": "cascade",
2415 + "onUpdate": "no action"
2416 + }
2417 + },
2418 + "compositePrimaryKeys": {
2419 + "user_model_favorites_user_id_model_key_pk": {
2420 + "name": "user_model_favorites_user_id_model_key_pk",
2421 + "columns": [
2422 + "user_id",
2423 + "model_key"
2424 + ]
2425 + }
2426 + },
2427 + "uniqueConstraints": {},
2428 + "policies": {},
2429 + "checkConstraints": {},
2430 + "isRLSEnabled": false
2431 + },
2432 + "public.user_model_recents": {
2433 + "name": "user_model_recents",
2434 + "schema": "",
2435 + "columns": {
2436 + "user_id": {
2437 + "name": "user_id",
2438 + "type": "text",
2439 + "primaryKey": false,
2440 + "notNull": true
2441 + },
2442 + "model_key": {
2443 + "name": "model_key",
2444 + "type": "text",
2445 + "primaryKey": false,
2446 + "notNull": true
2447 + },
2448 + "used_at": {
2449 + "name": "used_at",
2450 + "type": "timestamp with time zone",
2451 + "primaryKey": false,
2452 + "notNull": true,
2453 + "default": "now()"
2454 + },
2455 + "uses": {
2456 + "name": "uses",
2457 + "type": "integer",
2458 + "primaryKey": false,
2459 + "notNull": true,
2460 + "default": 1
2461 + }
2462 + },
2463 + "indexes": {},
2464 + "foreignKeys": {
2465 + "user_model_recents_user_id_users_id_fk": {
2466 + "name": "user_model_recents_user_id_users_id_fk",
2467 + "tableFrom": "user_model_recents",
2468 + "tableTo": "users",
2469 + "columnsFrom": [
2470 + "user_id"
2471 + ],
2472 + "columnsTo": [
2473 + "id"
2474 + ],
2475 + "onDelete": "cascade",
2476 + "onUpdate": "no action"
2477 + }
2478 + },
2479 + "compositePrimaryKeys": {
2480 + "user_model_recents_user_id_model_key_pk": {
2481 + "name": "user_model_recents_user_id_model_key_pk",
2482 + "columns": [
2483 + "user_id",
2484 + "model_key"
2485 + ]
2486 + }
2487 + },
2488 + "uniqueConstraints": {},
2489 + "policies": {},
2490 + "checkConstraints": {},
2491 + "isRLSEnabled": false
2492 + },
2493 + "public.user_preferences": {
2494 + "name": "user_preferences",
2495 + "schema": "",
2496 + "columns": {
2497 + "user_id": {
2498 + "name": "user_id",
2499 + "type": "text",
2500 + "primaryKey": true,
2501 + "notNull": true
2502 + },
2503 + "theme": {
2504 + "name": "theme",
2505 + "type": "text",
2506 + "primaryKey": false,
2507 + "notNull": true,
2508 + "default": "'system'"
2509 + },
2510 + "language": {
2511 + "name": "language",
2512 + "type": "text",
2513 + "primaryKey": false,
2514 + "notNull": true,
2515 + "default": "'en'"
2516 + },
2517 + "default_model_key": {
2518 + "name": "default_model_key",
2519 + "type": "text",
2520 + "primaryKey": false,
2521 + "notNull": false
2522 + },
2523 + "default_system_prompt": {
2524 + "name": "default_system_prompt",
2525 + "type": "text",
2526 + "primaryKey": false,
2527 + "notNull": false
2528 + },
2529 + "enter_to_send": {
2530 + "name": "enter_to_send",
2531 + "type": "boolean",
2532 + "primaryKey": false,
2533 + "notNull": true,
2534 + "default": true
2535 + },
2536 + "streaming": {
2537 + "name": "streaming",
2538 + "type": "boolean",
2539 + "primaryKey": false,
2540 + "notNull": true,
2541 + "default": true
2542 + },
2543 + "code_wrap": {
2544 + "name": "code_wrap",
2545 + "type": "boolean",
2546 + "primaryKey": false,
2547 + "notNull": true,
2548 + "default": false
2549 + },
2550 + "show_reasoning": {
2551 + "name": "show_reasoning",
2552 + "type": "boolean",
2553 + "primaryKey": false,
2554 + "notNull": true,
2555 + "default": true
2556 + },
2557 + "show_costs": {
2558 + "name": "show_costs",
2559 + "type": "boolean",
2560 + "primaryKey": false,
2561 + "notNull": true,
2562 + "default": true
2563 + },
2564 + "auto_title": {
2565 + "name": "auto_title",
2566 + "type": "boolean",
2567 + "primaryKey": false,
2568 + "notNull": true,
2569 + "default": true
2570 + },
2571 + "extra": {
2572 + "name": "extra",
2573 + "type": "jsonb",
2574 + "primaryKey": false,
2575 + "notNull": true,
2576 + "default": "'{}'::jsonb"
2577 + },
2578 + "updated_at": {
2579 + "name": "updated_at",
2580 + "type": "timestamp with time zone",
2581 + "primaryKey": false,
2582 + "notNull": true,
2583 + "default": "now()"
2584 + }
2585 + },
2586 + "indexes": {},
2587 + "foreignKeys": {
2588 + "user_preferences_user_id_users_id_fk": {
2589 + "name": "user_preferences_user_id_users_id_fk",
2590 + "tableFrom": "user_preferences",
2591 + "tableTo": "users",
2592 + "columnsFrom": [
2593 + "user_id"
2594 + ],
2595 + "columnsTo": [
2596 + "id"
2597 + ],
2598 + "onDelete": "cascade",
2599 + "onUpdate": "no action"
2600 + }
2601 + },
2602 + "compositePrimaryKeys": {},
2603 + "uniqueConstraints": {},
2604 + "policies": {},
2605 + "checkConstraints": {},
2606 + "isRLSEnabled": false
2607 + },
2608 + "public.users": {
2609 + "name": "users",
2610 + "schema": "",
2611 + "columns": {
2612 + "id": {
2613 + "name": "id",
2614 + "type": "text",
2615 + "primaryKey": true,
2616 + "notNull": true
2617 + },
2618 + "name": {
2619 + "name": "name",
2620 + "type": "text",
2621 + "primaryKey": false,
2622 + "notNull": true,
2623 + "default": "''"
2624 + },
2625 + "email": {
2626 + "name": "email",
2627 + "type": "text",
2628 + "primaryKey": false,
2629 + "notNull": true
2630 + },
2631 + "email_verified": {
2632 + "name": "email_verified",
2633 + "type": "boolean",
2634 + "primaryKey": false,
2635 + "notNull": true,
2636 + "default": false
2637 + },
2638 + "image": {
2639 + "name": "image",
2640 + "type": "text",
2641 + "primaryKey": false,
2642 + "notNull": false
2643 + },
2644 + "role": {
2645 + "name": "role",
2646 + "type": "text",
2647 + "primaryKey": false,
2648 + "notNull": true,
2649 + "default": "'user'"
2650 + },
2651 + "onboarding_completed_at": {
2652 + "name": "onboarding_completed_at",
2653 + "type": "timestamp with time zone",
2654 + "primaryKey": false,
2655 + "notNull": false
2656 + },
2657 + "created_at": {
2658 + "name": "created_at",
2659 + "type": "timestamp with time zone",
2660 + "primaryKey": false,
2661 + "notNull": true,
2662 + "default": "now()"
2663 + },
2664 + "updated_at": {
2665 + "name": "updated_at",
2666 + "type": "timestamp with time zone",
2667 + "primaryKey": false,
2668 + "notNull": true,
2669 + "default": "now()"
2670 + }
2671 + },
2672 + "indexes": {
2673 + "users_email_uq": {
2674 + "name": "users_email_uq",
2675 + "columns": [
2676 + {
2677 + "expression": "email",
2678 + "isExpression": false,
2679 + "asc": true,
2680 + "nulls": "last"
2681 + }
2682 + ],
2683 + "isUnique": true,
2684 + "concurrently": false,
2685 + "method": "btree",
2686 + "with": {}
2687 + }
2688 + },
2689 + "foreignKeys": {},
2690 + "compositePrimaryKeys": {},
2691 + "uniqueConstraints": {},
2692 + "policies": {},
2693 + "checkConstraints": {},
2694 + "isRLSEnabled": false
2695 + },
2696 + "public.verifications": {
2697 + "name": "verifications",
2698 + "schema": "",
2699 + "columns": {
2700 + "id": {
2701 + "name": "id",
2702 + "type": "text",
2703 + "primaryKey": true,
2704 + "notNull": true
2705 + },
2706 + "identifier": {
2707 + "name": "identifier",
2708 + "type": "text",
2709 + "primaryKey": false,
2710 + "notNull": true
2711 + },
2712 + "value": {
2713 + "name": "value",
2714 + "type": "text",
2715 + "primaryKey": false,
2716 + "notNull": true
2717 + },
2718 + "expires_at": {
2719 + "name": "expires_at",
2720 + "type": "timestamp with time zone",
2721 + "primaryKey": false,
2722 + "notNull": true
2723 + },
2724 + "created_at": {
2725 + "name": "created_at",
2726 + "type": "timestamp with time zone",
2727 + "primaryKey": false,
2728 + "notNull": true,
2729 + "default": "now()"
2730 + },
2731 + "updated_at": {
2732 + "name": "updated_at",
2733 + "type": "timestamp with time zone",
2734 + "primaryKey": false,
2735 + "notNull": true,
2736 + "default": "now()"
2737 + }
2738 + },
2739 + "indexes": {
2740 + "verifications_identifier_idx": {
2741 + "name": "verifications_identifier_idx",
2742 + "columns": [
2743 + {
2744 + "expression": "identifier",
2745 + "isExpression": false,
2746 + "asc": true,
2747 + "nulls": "last"
2748 + }
2749 + ],
2750 + "isUnique": false,
2751 + "concurrently": false,
2752 + "method": "btree",
2753 + "with": {}
2754 + }
2755 + },
2756 + "foreignKeys": {},
2757 + "compositePrimaryKeys": {},
2758 + "uniqueConstraints": {},
2759 + "policies": {},
2760 + "checkConstraints": {},
2761 + "isRLSEnabled": false
2762 + },
2763 + "public.arena_votes": {
2764 + "name": "arena_votes",
2765 + "schema": "",
2766 + "columns": {
2767 + "id": {
2768 + "name": "id",
2769 + "type": "text",
2770 + "primaryKey": true,
2771 + "notNull": true
2772 + },
2773 + "user_id": {
2774 + "name": "user_id",
2775 + "type": "text",
2776 + "primaryKey": false,
2777 + "notNull": true
2778 + },
2779 + "session_id": {
2780 + "name": "session_id",
2781 + "type": "text",
2782 + "primaryKey": false,
2783 + "notNull": true
2784 + },
2785 + "response_id": {
2786 + "name": "response_id",
2787 + "type": "text",
2788 + "primaryKey": false,
2789 + "notNull": true
2790 + },
2791 + "model_key": {
2792 + "name": "model_key",
2793 + "type": "text",
2794 + "primaryKey": false,
2795 + "notNull": true
2796 + },
2797 + "criterion": {
2798 + "name": "criterion",
2799 + "type": "text",
2800 + "primaryKey": false,
2801 + "notNull": true
2802 + },
2803 + "category": {
2804 + "name": "category",
2805 + "type": "text",
2806 + "primaryKey": false,
2807 + "notNull": false
2808 + },
2809 + "created_at": {
2810 + "name": "created_at",
2811 + "type": "timestamp with time zone",
2812 + "primaryKey": false,
2813 + "notNull": true,
2814 + "default": "now()"
2815 + }
2816 + },
2817 + "indexes": {
2818 + "arena_votes_session_criterion_uq": {
2819 + "name": "arena_votes_session_criterion_uq",
2820 + "columns": [
2821 + {
2822 + "expression": "session_id",
2823 + "isExpression": false,
2824 + "asc": true,
2825 + "nulls": "last"
2826 + },
2827 + {
2828 + "expression": "criterion",
2829 + "isExpression": false,
2830 + "asc": true,
2831 + "nulls": "last"
2832 + }
2833 + ],
2834 + "isUnique": true,
2835 + "concurrently": false,
2836 + "method": "btree",
2837 + "with": {}
2838 + },
2839 + "arena_votes_user_model_idx": {
2840 + "name": "arena_votes_user_model_idx",
2841 + "columns": [
2842 + {
2843 + "expression": "user_id",
2844 + "isExpression": false,
2845 + "asc": true,
2846 + "nulls": "last"
2847 + },
2848 + {
2849 + "expression": "model_key",
2850 + "isExpression": false,
2851 + "asc": true,
2852 + "nulls": "last"
2853 + }
2854 + ],
2855 + "isUnique": false,
2856 + "concurrently": false,
2857 + "method": "btree",
2858 + "with": {}
2859 + }
2860 + },
2861 + "foreignKeys": {
2862 + "arena_votes_user_id_users_id_fk": {
2863 + "name": "arena_votes_user_id_users_id_fk",
2864 + "tableFrom": "arena_votes",
2865 + "tableTo": "users",
2866 + "columnsFrom": [
2867 + "user_id"
2868 + ],
2869 + "columnsTo": [
2870 + "id"
2871 + ],
2872 + "onDelete": "cascade",
2873 + "onUpdate": "no action"
2874 + }
2875 + },
2876 + "compositePrimaryKeys": {},
2877 + "uniqueConstraints": {},
2878 + "policies": {},
2879 + "checkConstraints": {},
2880 + "isRLSEnabled": false
2881 + },
2882 + "public.custom_endpoints": {
2883 + "name": "custom_endpoints",
2884 + "schema": "",
2885 + "columns": {
2886 + "id": {
2887 + "name": "id",
2888 + "type": "text",
2889 + "primaryKey": true,
2890 + "notNull": true
2891 + },
2892 + "user_id": {
2893 + "name": "user_id",
2894 + "type": "text",
2895 + "primaryKey": false,
2896 + "notNull": true
2897 + },
2898 + "name": {
2899 + "name": "name",
2900 + "type": "text",
2901 + "primaryKey": false,
2902 + "notNull": true
2903 + },
2904 + "base_url": {
2905 + "name": "base_url",
2906 + "type": "text",
2907 + "primaryKey": false,
2908 + "notNull": true
2909 + },
2910 + "encrypted_key": {
2911 + "name": "encrypted_key",
2912 + "type": "text",
2913 + "primaryKey": false,
2914 + "notNull": false
2915 + },
2916 + "key_hint": {
2917 + "name": "key_hint",
2918 + "type": "text",
2919 + "primaryKey": false,
2920 + "notNull": false
2921 + },
2922 + "encrypted_headers": {
2923 + "name": "encrypted_headers",
2924 + "type": "text",
2925 + "primaryKey": false,
2926 + "notNull": false
2927 + },
2928 + "models_path": {
2929 + "name": "models_path",
2930 + "type": "text",
2931 + "primaryKey": false,
2932 + "notNull": true,
2933 + "default": "'/models'"
2934 + },
2935 + "manual_models": {
2936 + "name": "manual_models",
2937 + "type": "jsonb",
2938 + "primaryKey": false,
2939 + "notNull": true,
2940 + "default": "'[]'::jsonb"
2941 + },
2942 + "status": {
2943 + "name": "status",
2944 + "type": "text",
2945 + "primaryKey": false,
2946 + "notNull": true,
2947 + "default": "'unverified'"
2948 + },
2949 + "last_validated_at": {
2950 + "name": "last_validated_at",
2951 + "type": "timestamp with time zone",
2952 + "primaryKey": false,
2953 + "notNull": false
2954 + },
2955 + "last_validation_error": {
2956 + "name": "last_validation_error",
2957 + "type": "text",
2958 + "primaryKey": false,
2959 + "notNull": false
2960 + },
2961 + "last_latency_ms": {
2962 + "name": "last_latency_ms",
2963 + "type": "integer",
2964 + "primaryKey": false,
2965 + "notNull": false
2966 + },
2967 + "models_available": {
2968 + "name": "models_available",
2969 + "type": "integer",
2970 + "primaryKey": false,
2971 + "notNull": false
2972 + },
2973 + "discovered_models": {
2974 + "name": "discovered_models",
2975 + "type": "jsonb",
2976 + "primaryKey": false,
2977 + "notNull": true,
2978 + "default": "'[]'::jsonb"
2979 + },
2980 + "discovered_at": {
2981 + "name": "discovered_at",
2982 + "type": "timestamp with time zone",
2983 + "primaryKey": false,
2984 + "notNull": false
2985 + },
2986 + "created_at": {
2987 + "name": "created_at",
2988 + "type": "timestamp with time zone",
2989 + "primaryKey": false,
2990 + "notNull": true,
2991 + "default": "now()"
2992 + },
2993 + "updated_at": {
2994 + "name": "updated_at",
2995 + "type": "timestamp with time zone",
2996 + "primaryKey": false,
2997 + "notNull": true,
2998 + "default": "now()"
2999 + }
3000 + },
3001 + "indexes": {
3002 + "custom_endpoints_user_idx": {
3003 + "name": "custom_endpoints_user_idx",
3004 + "columns": [
3005 + {
3006 + "expression": "user_id",
3007 + "isExpression": false,
3008 + "asc": true,
3009 + "nulls": "last"
3010 + }
3011 + ],
3012 + "isUnique": false,
3013 + "concurrently": false,
3014 + "method": "btree",
3015 + "with": {}
3016 + }
3017 + },
3018 + "foreignKeys": {
3019 + "custom_endpoints_user_id_users_id_fk": {
3020 + "name": "custom_endpoints_user_id_users_id_fk",
3021 + "tableFrom": "custom_endpoints",
3022 + "tableTo": "users",
3023 + "columnsFrom": [
3024 + "user_id"
3025 + ],
3026 + "columnsTo": [
3027 + "id"
3028 + ],
3029 + "onDelete": "cascade",
3030 + "onUpdate": "no action"
3031 + }
3032 + },
3033 + "compositePrimaryKeys": {},
3034 + "uniqueConstraints": {},
3035 + "policies": {},
3036 + "checkConstraints": {},
3037 + "isRLSEnabled": false
3038 + },
3039 + "public.project_files": {
3040 + "name": "project_files",
3041 + "schema": "",
3042 + "columns": {
3043 + "id": {
3044 + "name": "id",
3045 + "type": "text",
3046 + "primaryKey": true,
3047 + "notNull": true
3048 + },
3049 + "user_id": {
3050 + "name": "user_id",
3051 + "type": "text",
3052 + "primaryKey": false,
3053 + "notNull": true
3054 + },
3055 + "project_id": {
3056 + "name": "project_id",
3057 + "type": "text",
3058 + "primaryKey": false,
3059 + "notNull": false
3060 + },
3061 + "kind": {
3062 + "name": "kind",
3063 + "type": "text",
3064 + "primaryKey": false,
3065 + "notNull": true
3066 + },
3067 + "name": {
3068 + "name": "name",
3069 + "type": "text",
3070 + "primaryKey": false,
3071 + "notNull": true
3072 + },
3073 + "mime_type": {
3074 + "name": "mime_type",
3075 + "type": "text",
3076 + "primaryKey": false,
3077 + "notNull": true
3078 + },
3079 + "size_bytes": {
3080 + "name": "size_bytes",
3081 + "type": "integer",
3082 + "primaryKey": false,
3083 + "notNull": true
3084 + },
3085 + "data_base64": {
3086 + "name": "data_base64",
3087 + "type": "text",
3088 + "primaryKey": false,
3089 + "notNull": true
3090 + },
3091 + "width": {
3092 + "name": "width",
3093 + "type": "integer",
3094 + "primaryKey": false,
3095 + "notNull": false
3096 + },
3097 + "height": {
3098 + "name": "height",
3099 + "type": "integer",
3100 + "primaryKey": false,
3101 + "notNull": false
3102 + },
3103 + "estimated_tokens": {
3104 + "name": "estimated_tokens",
3105 + "type": "integer",
3106 + "primaryKey": false,
3107 + "notNull": false
3108 + },
3109 + "description": {
3110 + "name": "description",
3111 + "type": "text",
3112 + "primaryKey": false,
3113 + "notNull": false
3114 + },
3115 + "created_at": {
3116 + "name": "created_at",
3117 + "type": "timestamp with time zone",
3118 + "primaryKey": false,
3119 + "notNull": true,
3120 + "default": "now()"
3121 + }
3122 + },
3123 + "indexes": {
3124 + "project_files_user_idx": {
3125 + "name": "project_files_user_idx",
3126 + "columns": [
3127 + {
3128 + "expression": "user_id",
3129 + "isExpression": false,
3130 + "asc": true,
3131 + "nulls": "last"
3132 + }
3133 + ],
3134 + "isUnique": false,
3135 + "concurrently": false,
3136 + "method": "btree",
3137 + "with": {}
3138 + },
3139 + "project_files_project_idx": {
3140 + "name": "project_files_project_idx",
3141 + "columns": [
3142 + {
3143 + "expression": "project_id",
3144 + "isExpression": false,
3145 + "asc": true,
3146 + "nulls": "last"
3147 + }
3148 + ],
3149 + "isUnique": false,
3150 + "concurrently": false,
3151 + "method": "btree",
3152 + "with": {}
3153 + }
3154 + },
3155 + "foreignKeys": {
3156 + "project_files_user_id_users_id_fk": {
3157 + "name": "project_files_user_id_users_id_fk",
3158 + "tableFrom": "project_files",
3159 + "tableTo": "users",
3160 + "columnsFrom": [
3161 + "user_id"
3162 + ],
3163 + "columnsTo": [
3164 + "id"
3165 + ],
3166 + "onDelete": "cascade",
3167 + "onUpdate": "no action"
3168 + },
3169 + "project_files_project_id_projects_id_fk": {
3170 + "name": "project_files_project_id_projects_id_fk",
3171 + "tableFrom": "project_files",
3172 + "tableTo": "projects",
3173 + "columnsFrom": [
3174 + "project_id"
3175 + ],
3176 + "columnsTo": [
3177 + "id"
3178 + ],
3179 + "onDelete": "cascade",
3180 + "onUpdate": "no action"
3181 + }
3182 + },
3183 + "compositePrimaryKeys": {},
3184 + "uniqueConstraints": {},
3185 + "policies": {},
3186 + "checkConstraints": {},
3187 + "isRLSEnabled": false
3188 + },
3189 + "public.projects": {
3190 + "name": "projects",
3191 + "schema": "",
3192 + "columns": {
3193 + "id": {
3194 + "name": "id",
3195 + "type": "text",
3196 + "primaryKey": true,
3197 + "notNull": true
3198 + },
3199 + "user_id": {
3200 + "name": "user_id",
3201 + "type": "text",
3202 + "primaryKey": false,
3203 + "notNull": true
3204 + },
3205 + "name": {
3206 + "name": "name",
3207 + "type": "text",
3208 + "primaryKey": false,
3209 + "notNull": true
3210 + },
3211 + "description": {
3212 + "name": "description",
3213 + "type": "text",
3214 + "primaryKey": false,
3215 + "notNull": false
3216 + },
3217 + "icon": {
3218 + "name": "icon",
3219 + "type": "text",
3220 + "primaryKey": false,
3221 + "notNull": false
3222 + },
3223 + "color": {
3224 + "name": "color",
3225 + "type": "text",
3226 + "primaryKey": false,
3227 + "notNull": false
3228 + },
3229 + "instructions": {
3230 + "name": "instructions",
3231 + "type": "text",
3232 + "primaryKey": false,
3233 + "notNull": false
3234 + },
3235 + "preferred_model_keys": {
3236 + "name": "preferred_model_keys",
3237 + "type": "jsonb",
3238 + "primaryKey": false,
3239 + "notNull": true,
3240 + "default": "'[]'::jsonb"
3241 + },
3242 + "default_settings": {
3243 + "name": "default_settings",
3244 + "type": "jsonb",
3245 + "primaryKey": false,
3246 + "notNull": true,
3247 + "default": "'{}'::jsonb"
3248 + },
3249 + "notes": {
3250 + "name": "notes",
3251 + "type": "text",
3252 + "primaryKey": false,
3253 + "notNull": false
3254 + },
3255 + "archived": {
3256 + "name": "archived",
3257 + "type": "boolean",
3258 + "primaryKey": false,
3259 + "notNull": true,
3260 + "default": false
3261 + },
3262 + "sort_order": {
3263 + "name": "sort_order",
3264 + "type": "integer",
3265 + "primaryKey": false,
3266 + "notNull": true,
3267 + "default": 0
3268 + },
3269 + "created_at": {
3270 + "name": "created_at",
3271 + "type": "timestamp with time zone",
3272 + "primaryKey": false,
3273 + "notNull": true,
3274 + "default": "now()"
3275 + },
3276 + "updated_at": {
3277 + "name": "updated_at",
3278 + "type": "timestamp with time zone",
3279 + "primaryKey": false,
3280 + "notNull": true,
3281 + "default": "now()"
3282 + }
3283 + },
3284 + "indexes": {
3285 + "projects_user_idx": {
3286 + "name": "projects_user_idx",
3287 + "columns": [
3288 + {
3289 + "expression": "user_id",
3290 + "isExpression": false,
3291 + "asc": true,
3292 + "nulls": "last"
3293 + }
3294 + ],
3295 + "isUnique": false,
3296 + "concurrently": false,
3297 + "method": "btree",
3298 + "with": {}
3299 + }
3300 + },
3301 + "foreignKeys": {
3302 + "projects_user_id_users_id_fk": {
3303 + "name": "projects_user_id_users_id_fk",
3304 + "tableFrom": "projects",
3305 + "tableTo": "users",
3306 + "columnsFrom": [
3307 + "user_id"
3308 + ],
3309 + "columnsTo": [
3310 + "id"
3311 + ],
3312 + "onDelete": "cascade",
3313 + "onUpdate": "no action"
3314 + }
3315 + },
3316 + "compositePrimaryKeys": {},
3317 + "uniqueConstraints": {},
3318 + "policies": {},
3319 + "checkConstraints": {},
3320 + "isRLSEnabled": false
3321 + },
3322 + "public.prompts": {
3323 + "name": "prompts",
3324 + "schema": "",
3325 + "columns": {
3326 + "id": {
3327 + "name": "id",
3328 + "type": "text",
3329 + "primaryKey": true,
3330 + "notNull": true
3331 + },
3332 + "user_id": {
3333 + "name": "user_id",
3334 + "type": "text",
3335 + "primaryKey": false,
3336 + "notNull": true
3337 + },
3338 + "project_id": {
3339 + "name": "project_id",
3340 + "type": "text",
3341 + "primaryKey": false,
3342 + "notNull": false
3343 + },
3344 + "kind": {
3345 + "name": "kind",
3346 + "type": "text",
3347 + "primaryKey": false,
3348 + "notNull": true,
3349 + "default": "'user'"
3350 + },
3351 + "name": {
3352 + "name": "name",
3353 + "type": "text",
3354 + "primaryKey": false,
3355 + "notNull": true
3356 + },
3357 + "description": {
3358 + "name": "description",
3359 + "type": "text",
3360 + "primaryKey": false,
3361 + "notNull": false
3362 + },
3363 + "content": {
3364 + "name": "content",
3365 + "type": "text",
3366 + "primaryKey": false,
3367 + "notNull": true
3368 + },
3369 + "variables": {
3370 + "name": "variables",
3371 + "type": "jsonb",
3372 + "primaryKey": false,
3373 + "notNull": true,
3374 + "default": "'[]'::jsonb"
3375 + },
3376 + "schema": {
3377 + "name": "schema",
3378 + "type": "jsonb",
3379 + "primaryKey": false,
3380 + "notNull": false
3381 + },
3382 + "folder": {
3383 + "name": "folder",
3384 + "type": "text",
3385 + "primaryKey": false,
3386 + "notNull": false
3387 + },
3388 + "tags": {
3389 + "name": "tags",
3390 + "type": "jsonb",
3391 + "primaryKey": false,
3392 + "notNull": true,
3393 + "default": "'[]'::jsonb"
3394 + },
3395 + "favorite": {
3396 + "name": "favorite",
3397 + "type": "boolean",
3398 + "primaryKey": false,
3399 + "notNull": true,
3400 + "default": false
3401 + },
3402 + "default_model_key": {
3403 + "name": "default_model_key",
3404 + "type": "text",
3405 + "primaryKey": false,
3406 + "notNull": false
3407 + },
3408 + "uses": {
3409 + "name": "uses",
3410 + "type": "integer",
3411 + "primaryKey": false,
3412 + "notNull": true,
3413 + "default": 0
3414 + },
3415 + "last_used_at": {
3416 + "name": "last_used_at",
3417 + "type": "timestamp with time zone",
3418 + "primaryKey": false,
3419 + "notNull": false
3420 + },
3421 + "created_at": {
3422 + "name": "created_at",
3423 + "type": "timestamp with time zone",
3424 + "primaryKey": false,
3425 + "notNull": true,
3426 + "default": "now()"
3427 + },
3428 + "updated_at": {
3429 + "name": "updated_at",
3430 + "type": "timestamp with time zone",
3431 + "primaryKey": false,
3432 + "notNull": true,
3433 + "default": "now()"
3434 + }
3435 + },
3436 + "indexes": {
3437 + "prompts_user_idx": {
3438 + "name": "prompts_user_idx",
3439 + "columns": [
3440 + {
3441 + "expression": "user_id",
3442 + "isExpression": false,
3443 + "asc": true,
3444 + "nulls": "last"
3445 + }
3446 + ],
3447 + "isUnique": false,
3448 + "concurrently": false,
3449 + "method": "btree",
3450 + "with": {}
3451 + },
3452 + "prompts_user_folder_idx": {
3453 + "name": "prompts_user_folder_idx",
3454 + "columns": [
3455 + {
3456 + "expression": "user_id",
3457 + "isExpression": false,
3458 + "asc": true,
3459 + "nulls": "last"
3460 + },
3461 + {
3462 + "expression": "folder",
3463 + "isExpression": false,
3464 + "asc": true,
3465 + "nulls": "last"
3466 + }
3467 + ],
3468 + "isUnique": false,
3469 + "concurrently": false,
3470 + "method": "btree",
3471 + "with": {}
3472 + }
3473 + },
3474 + "foreignKeys": {
3475 + "prompts_user_id_users_id_fk": {
3476 + "name": "prompts_user_id_users_id_fk",
3477 + "tableFrom": "prompts",
3478 + "tableTo": "users",
3479 + "columnsFrom": [
3480 + "user_id"
3481 + ],
3482 + "columnsTo": [
3483 + "id"
3484 + ],
3485 + "onDelete": "cascade",
3486 + "onUpdate": "no action"
3487 + },
3488 + "prompts_project_id_projects_id_fk": {
3489 + "name": "prompts_project_id_projects_id_fk",
3490 + "tableFrom": "prompts",
3491 + "tableTo": "projects",
3492 + "columnsFrom": [
3493 + "project_id"
3494 + ],
3495 + "columnsTo": [
3496 + "id"
3497 + ],
3498 + "onDelete": "set null",
3499 + "onUpdate": "no action"
3500 + }
3501 + },
3502 + "compositePrimaryKeys": {},
3503 + "uniqueConstraints": {},
3504 + "policies": {},
3505 + "checkConstraints": {},
3506 + "isRLSEnabled": false
3507 + },
3508 + "public.shared_arena_sessions": {
3509 + "name": "shared_arena_sessions",
3510 + "schema": "",
3511 + "columns": {
3512 + "id": {
3513 + "name": "id",
3514 + "type": "text",
3515 + "primaryKey": true,
3516 + "notNull": true
3517 + },
3518 + "session_id": {
3519 + "name": "session_id",
3520 + "type": "text",
3521 + "primaryKey": false,
3522 + "notNull": true
3523 + },
3524 + "user_id": {
3525 + "name": "user_id",
3526 + "type": "text",
3527 + "primaryKey": false,
3528 + "notNull": true
3529 + },
3530 + "snapshot": {
3531 + "name": "snapshot",
3532 + "type": "jsonb",
3533 + "primaryKey": false,
3534 + "notNull": true
3535 + },
3536 + "is_public": {
3537 + "name": "is_public",
3538 + "type": "boolean",
3539 + "primaryKey": false,
3540 + "notNull": true,
3541 + "default": true
3542 + },
3543 + "view_count": {
3544 + "name": "view_count",
3545 + "type": "integer",
3546 + "primaryKey": false,
3547 + "notNull": true,
3548 + "default": 0
3549 + },
3550 + "created_at": {
3551 + "name": "created_at",
3552 + "type": "timestamp with time zone",
3553 + "primaryKey": false,
3554 + "notNull": true,
3555 + "default": "now()"
3556 + },
3557 + "revoked_at": {
3558 + "name": "revoked_at",
3559 + "type": "timestamp with time zone",
3560 + "primaryKey": false,
3561 + "notNull": false
3562 + }
3563 + },
3564 + "indexes": {
3565 + "shared_arena_session_idx": {
3566 + "name": "shared_arena_session_idx",
3567 + "columns": [
3568 + {
3569 + "expression": "session_id",
3570 + "isExpression": false,
3571 + "asc": true,
3572 + "nulls": "last"
3573 + }
3574 + ],
3575 + "isUnique": false,
3576 + "concurrently": false,
3577 + "method": "btree",
3578 + "with": {}
3579 + }
3580 + },
3581 + "foreignKeys": {
3582 + "shared_arena_sessions_user_id_users_id_fk": {
3583 + "name": "shared_arena_sessions_user_id_users_id_fk",
3584 + "tableFrom": "shared_arena_sessions",
3585 + "tableTo": "users",
3586 + "columnsFrom": [
3587 + "user_id"
3588 + ],
3589 + "columnsTo": [
3590 + "id"
3591 + ],
3592 + "onDelete": "cascade",
3593 + "onUpdate": "no action"
3594 + }
3595 + },
3596 + "compositePrimaryKeys": {},
3597 + "uniqueConstraints": {},
3598 + "policies": {},
3599 + "checkConstraints": {},
3600 + "isRLSEnabled": false
3601 + },
3602 + "public.user_model_labels": {
3603 + "name": "user_model_labels",
3604 + "schema": "",
3605 + "columns": {
3606 + "user_id": {
3607 + "name": "user_id",
3608 + "type": "text",
3609 + "primaryKey": false,
3610 + "notNull": true
3611 + },
3612 + "model_key": {
3613 + "name": "model_key",
3614 + "type": "text",
3615 + "primaryKey": false,
3616 + "notNull": true
3617 + },
3618 + "label": {
3619 + "name": "label",
3620 + "type": "text",
3621 + "primaryKey": false,
3622 + "notNull": true
3623 + },
3624 + "updated_at": {
3625 + "name": "updated_at",
3626 + "type": "timestamp with time zone",
3627 + "primaryKey": false,
3628 + "notNull": true,
3629 + "default": "now()"
3630 + }
3631 + },
3632 + "indexes": {},
3633 + "foreignKeys": {
3634 + "user_model_labels_user_id_users_id_fk": {
3635 + "name": "user_model_labels_user_id_users_id_fk",
3636 + "tableFrom": "user_model_labels",
3637 + "tableTo": "users",
3638 + "columnsFrom": [
3639 + "user_id"
3640 + ],
3641 + "columnsTo": [
3642 + "id"
3643 + ],
3644 + "onDelete": "cascade",
3645 + "onUpdate": "no action"
3646 + }
3647 + },
3648 + "compositePrimaryKeys": {
3649 + "user_model_labels_user_id_model_key_pk": {
3650 + "name": "user_model_labels_user_id_model_key_pk",
3651 + "columns": [
3652 + "user_id",
3653 + "model_key"
3654 + ]
3655 + }
3656 + },
3657 + "uniqueConstraints": {},
3658 + "policies": {},
3659 + "checkConstraints": {},
3660 + "isRLSEnabled": false
3661 + }
3662 + },
3663 + "enums": {},
3664 + "schemas": {},
3665 + "sequences": {},
3666 + "roles": {},
3667 + "policies": {},
3668 + "views": {},
3669 + "_meta": {
3670 + "columns": {},
3671 + "schemas": {},
3672 + "tables": {}
3673 + }
3674 +}
\ No newline at end of file
modified drizzle/meta/_journal.json +14 −0
@@ -15,6 +15,20 @@
15 15 "when": 1789110687474,
16 16 "tag": "0001_workspace_upgrade",
17 17 "breakpoints": true
18 + },
19 + {
20 + "idx": 2,
21 + "version": "7",
22 + "when": 1789111681098,
23 + "tag": "0002_endpoints",
24 + "breakpoints": true
25 + },
26 + {
27 + "idx": 3,
28 + "version": "7",
29 + "when": 1789200000000,
30 + "tag": "0003_search_index",
31 + "breakpoints": true
18 32 }
19 33 ]
20 34 }
\ No newline at end of file
modified package.json +3 −0
@@ -44,6 +44,7 @@
44 44 "cmdk": "^1.1.1",
45 45 "drizzle-orm": "^0.45.2",
46 46 "geist": "^1.5.0",
47 + "katex": "^0.18.7",
47 48 "lucide-react": "^1.42.0",
48 49 "motion": "^12.23.0",
49 50 "next": "16.3.4",
@@ -54,8 +55,10 @@
54 55 "react-dom": "19.2.8",
55 56 "react-markdown": "^10.1.0",
56 57 "recharts": "^3.0.0",
58 + "rehype-katex": "^7.0.1",
57 59 "rehype-raw": "^7.0.0",
58 60 "remark-gfm": "^4.0.1",
61 + "remark-math": "^6.0.0",
59 62 "resend": "^6.26.0",
60 63 "server-only": "^0.0.1",
61 64 "shiki": "^4.4.3",
modified pnpm-lock.yaml +159 −0
@@ -68,6 +68,9 @@ importers:
68 68 geist:
69 69 specifier: ^1.5.0
70 70 version: 1.7.2(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))
71 + katex:
72 + specifier: ^0.18.7
73 + version: 0.18.7
71 74 lucide-react:
72 75 specifier: ^1.42.0
73 76 version: 1.42.0(react@19.2.8)
@@ -98,12 +101,18 @@ importers:
98 101 recharts:
99 102 specifier: ^3.0.0
100 103 version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@16.13.1)(react@19.2.8)(redux@5.0.1)
104 + rehype-katex:
105 + specifier: ^7.0.1
106 + version: 7.0.1
101 107 rehype-raw:
102 108 specifier: ^7.0.0
103 109 version: 7.0.0
104 110 remark-gfm:
105 111 specifier: ^4.0.1
106 112 version: 4.0.1
113 + remark-math:
114 + specifier: ^6.0.0
115 + version: 6.0.0
107 116 resend:
108 117 specifier: ^6.26.0
109 118 version: 6.26.0
@@ -1948,6 +1957,9 @@ packages:
1948 1957 '@types/json5@0.0.29':
1949 1958 resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
1950 1959
1960 + '@types/katex@0.16.8':
1961 + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
1962 +
1951 1963 '@types/mdast@4.0.4':
1952 1964 resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
1953 1965
@@ -2471,6 +2483,14 @@ packages:
2471 2483 comma-separated-tokens@2.0.3:
2472 2484 resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
2473 2485
2486 + commander@15.0.0:
2487 + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
2488 + engines: {node: '>=22.12.0'}
2489 +
2490 + commander@8.3.0:
2491 + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
2492 + engines: {node: '>= 12'}
2493 +
2474 2494 concat-map@0.0.1:
2475 2495 resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
2476 2496
@@ -3124,9 +3144,21 @@ packages:
3124 3144 resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
3125 3145 engines: {node: '>= 0.4'}
3126 3146
3147 + hast-util-from-dom@5.0.1:
3148 + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
3149 +
3150 + hast-util-from-html-isomorphic@2.0.0:
3151 + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
3152 +
3153 + hast-util-from-html@2.0.3:
3154 + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
3155 +
3127 3156 hast-util-from-parse5@8.0.3:
3128 3157 resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
3129 3158
3159 + hast-util-is-element@3.0.0:
3160 + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
3161 +
3130 3162 hast-util-parse-selector@4.0.0:
3131 3163 resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
3132 3164
@@ -3142,6 +3174,9 @@ packages:
3142 3174 hast-util-to-parse5@8.0.1:
3143 3175 resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
3144 3176
3177 + hast-util-to-text@4.0.2:
3178 + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
3179 +
3145 3180 hast-util-whitespace@3.0.0:
3146 3181 resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
3147 3182
@@ -3384,6 +3419,14 @@ packages:
3384 3419 jws@4.0.1:
3385 3420 resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
3386 3421
3422 + katex@0.16.47:
3423 + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
3424 + hasBin: true
3425 +
3426 + katex@0.18.7:
3427 + resolution: {integrity: sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==}
3428 + hasBin: true
3429 +
3387 3430 keyv@4.5.4:
3388 3431 resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
3389 3432
@@ -3538,6 +3581,9 @@ packages:
3538 3581 mdast-util-gfm@3.1.0:
3539 3582 resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
3540 3583
3584 + mdast-util-math@3.0.0:
3585 + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
3586 +
3541 3587 mdast-util-mdx-expression@2.0.1:
3542 3588 resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
3543 3589
@@ -3587,6 +3633,9 @@ packages:
3587 3633 micromark-extension-gfm@3.0.0:
3588 3634 resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
3589 3635
3636 + micromark-extension-math@3.1.0:
3637 + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
3638 +
3590 3639 micromark-factory-destination@2.0.1:
3591 3640 resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
3592 3641
@@ -4063,12 +4112,18 @@ packages:
4063 4112 resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
4064 4113 engines: {node: '>= 0.4'}
4065 4114
4115 + rehype-katex@7.0.1:
4116 + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
4117 +
4066 4118 rehype-raw@7.0.0:
4067 4119 resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
4068 4120
4069 4121 remark-gfm@4.0.1:
4070 4122 resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
4071 4123
4124 + remark-math@6.0.0:
4125 + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
4126 +
4072 4127 remark-parse@11.0.0:
4073 4128 resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
4074 4129
@@ -4419,12 +4474,18 @@ packages:
4419 4474 unified@11.0.5:
4420 4475 resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
4421 4476
4477 + unist-util-find-after@5.0.0:
4478 + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
4479 +
4422 4480 unist-util-is@6.0.1:
4423 4481 resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
4424 4482
4425 4483 unist-util-position@5.0.0:
4426 4484 resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
4427 4485
4486 + unist-util-remove-position@5.0.0:
4487 + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
4488 +
4428 4489 unist-util-stringify-position@4.0.0:
4429 4490 resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
4430 4491
@@ -6056,6 +6117,8 @@ snapshots:
6056 6117
6057 6118 '@types/json5@0.0.29': {}
6058 6119
6120 + '@types/katex@0.16.8': {}
6121 +
6059 6122 '@types/mdast@4.0.4':
6060 6123 dependencies:
6061 6124 '@types/unist': 3.0.3
@@ -6561,6 +6624,10 @@ snapshots:
6561 6624
6562 6625 comma-separated-tokens@2.0.3: {}
6563 6626
6627 + commander@15.0.0: {}
6628 +
6629 + commander@8.3.0: {}
6630 +
6564 6631 concat-map@0.0.1: {}
6565 6632
6566 6633 convert-source-map@2.0.0: {}
@@ -7334,6 +7401,28 @@ snapshots:
7334 7401 dependencies:
7335 7402 function-bind: 1.1.2
7336 7403
7404 + hast-util-from-dom@5.0.1:
7405 + dependencies:
7406 + '@types/hast': 3.0.5
7407 + hastscript: 9.0.1
7408 + web-namespaces: 2.0.1
7409 +
7410 + hast-util-from-html-isomorphic@2.0.0:
7411 + dependencies:
7412 + '@types/hast': 3.0.5
7413 + hast-util-from-dom: 5.0.1
7414 + hast-util-from-html: 2.0.3
7415 + unist-util-remove-position: 5.0.0
7416 +
7417 + hast-util-from-html@2.0.3:
7418 + dependencies:
7419 + '@types/hast': 3.0.5
7420 + devlop: 1.1.0
7421 + hast-util-from-parse5: 8.0.3
7422 + parse5: 7.3.0
7423 + vfile: 6.0.3
7424 + vfile-message: 4.0.3
7425 +
7337 7426 hast-util-from-parse5@8.0.3:
7338 7427 dependencies:
7339 7428 '@types/hast': 3.0.5
@@ -7345,6 +7434,10 @@ snapshots:
7345 7434 vfile-location: 5.0.3
7346 7435 web-namespaces: 2.0.1
7347 7436
7437 + hast-util-is-element@3.0.0:
7438 + dependencies:
7439 + '@types/hast': 3.0.5
7440 +
7348 7441 hast-util-parse-selector@4.0.0:
7349 7442 dependencies:
7350 7443 '@types/hast': 3.0.5
@@ -7409,6 +7502,13 @@ snapshots:
7409 7502 web-namespaces: 2.0.1
7410 7503 zwitch: 2.0.4
7411 7504
7505 + hast-util-to-text@4.0.2:
7506 + dependencies:
7507 + '@types/hast': 3.0.5
7508 + '@types/unist': 3.0.3
7509 + hast-util-is-element: 3.0.0
7510 + unist-util-find-after: 5.0.0
7511 +
7412 7512 hast-util-whitespace@3.0.0:
7413 7513 dependencies:
7414 7514 '@types/hast': 3.0.5
@@ -7656,6 +7756,14 @@ snapshots:
7656 7756 jwa: 2.0.1
7657 7757 safe-buffer: 5.2.1
7658 7758
7759 + katex@0.16.47:
7760 + dependencies:
7761 + commander: 8.3.0
7762 +
7763 + katex@0.18.7:
7764 + dependencies:
7765 + commander: 15.0.0
7766 +
7659 7767 keyv@4.5.4:
7660 7768 dependencies:
7661 7769 json-buffer: 3.0.1
@@ -7835,6 +7943,18 @@ snapshots:
7835 7943 transitivePeerDependencies:
7836 7944 - supports-color
7837 7945
7946 + mdast-util-math@3.0.0:
7947 + dependencies:
7948 + '@types/hast': 3.0.5
7949 + '@types/mdast': 4.0.4
7950 + devlop: 1.1.0
7951 + longest-streak: 3.1.0
7952 + mdast-util-from-markdown: 2.0.3
7953 + mdast-util-to-markdown: 2.1.2
7954 + unist-util-remove-position: 5.0.0
7955 + transitivePeerDependencies:
7956 + - supports-color
7957 +
7838 7958 mdast-util-mdx-expression@2.0.1:
7839 7959 dependencies:
7840 7960 '@types/estree-jsx': 1.0.5
@@ -7986,6 +8106,16 @@ snapshots:
7986 8106 micromark-util-combine-extensions: 2.0.1
7987 8107 micromark-util-types: 2.0.2
7988 8108
8109 + micromark-extension-math@3.1.0:
8110 + dependencies:
8111 + '@types/katex': 0.16.8
8112 + devlop: 1.1.0
8113 + katex: 0.16.47
8114 + micromark-factory-space: 2.0.1
8115 + micromark-util-character: 2.1.1
8116 + micromark-util-symbol: 2.0.1
8117 + micromark-util-types: 2.0.2
8118 +
7989 8119 micromark-factory-destination@2.0.1:
7990 8120 dependencies:
7991 8121 micromark-util-character: 2.1.1
@@ -8523,6 +8653,16 @@ snapshots:
8523 8653 gopd: 1.2.0
8524 8654 set-function-name: 2.0.2
8525 8655
8656 + rehype-katex@7.0.1:
8657 + dependencies:
8658 + '@types/hast': 3.0.5
8659 + '@types/katex': 0.16.8
8660 + hast-util-from-html-isomorphic: 2.0.0
8661 + hast-util-to-text: 4.0.2
8662 + katex: 0.16.47
8663 + unist-util-visit-parents: 6.0.2
8664 + vfile: 6.0.3
8665 +
8526 8666 rehype-raw@7.0.0:
8527 8667 dependencies:
8528 8668 '@types/hast': 3.0.5
@@ -8540,6 +8680,15 @@ snapshots:
8540 8680 transitivePeerDependencies:
8541 8681 - supports-color
8542 8682
8683 + remark-math@6.0.0:
8684 + dependencies:
8685 + '@types/mdast': 4.0.4
8686 + mdast-util-math: 3.0.0
8687 + micromark-extension-math: 3.1.0
8688 + unified: 11.0.5
8689 + transitivePeerDependencies:
8690 + - supports-color
8691 +
8543 8692 remark-parse@11.0.0:
8544 8693 dependencies:
8545 8694 '@types/mdast': 4.0.4
@@ -9005,6 +9154,11 @@ snapshots:
9005 9154 trough: 2.2.0
9006 9155 vfile: 6.0.3
9007 9156
9157 + unist-util-find-after@5.0.0:
9158 + dependencies:
9159 + '@types/unist': 3.0.3
9160 + unist-util-is: 6.0.1
9161 +
9008 9162 unist-util-is@6.0.1:
9009 9163 dependencies:
9010 9164 '@types/unist': 3.0.3
@@ -9013,6 +9167,11 @@ snapshots:
9013 9167 dependencies:
9014 9168 '@types/unist': 3.0.3
9015 9169
9170 + unist-util-remove-position@5.0.0:
9171 + dependencies:
9172 + '@types/unist': 3.0.3
9173 + unist-util-visit: 5.1.0
9174 +
9016 9175 unist-util-stringify-position@4.0.0:
9017 9176 dependencies:
9018 9177 '@types/unist': 3.0.3
added qa/responsive-qa.mjs +148 −0
@@ -0,0 +1,148 @@
1 +#!/usr/bin/env node
2 +/**
3 + * Responsive QA sweep — screenshots + automated checks for every main screen at the phone widths from the brief
4 + * (375×812, 390×844, 393×852, 430×932), a tablet and a desktop size.
5 + *
6 + * Checks per page: horizontal document overflow, elements extending past the right edge, tap targets < 40 px on
7 + * touch viewports, fixed elements covering the composer, console/page errors.
8 + *
9 + * node qa/responsive-qa.mjs [--base http://localhost:3000] [--only chat,arena] [--widths 390,430]
10 + * Session: reuses qa/.e2e-session.json (Playwright storageState) or logs in with QA_EMAIL / QA_PASSWORD.
11 + */
12 +import fs from "node:fs";
13 +import path from "node:path";
14 +import { chromium } from "@playwright/test";
15 +
16 +const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, "").split("=")).map(([k, v]) => [k, v ?? "1"]));
17 +const BASE = args.base ?? process.env.QA_BASE ?? "http://localhost:3000";
18 +const OUT = path.resolve("qa/out");
19 +const VIEWPORTS = [
20 + { name: "375", width: 375, height: 812, mobile: true },
21 + { name: "390", width: 390, height: 844, mobile: true },
22 + { name: "393", width: 393, height: 852, mobile: true },
23 + { name: "430", width: 430, height: 932, mobile: true },
24 + { name: "tablet", width: 820, height: 1180, mobile: true },
25 + { name: "desktop", width: 1440, height: 900, mobile: false },
26 +].filter((v) => !args.widths || args.widths.split(",").includes(v.name));
27 +
28 +const SCREENS = [
29 + { key: "home", path: "/", public: true },
30 + { key: "models-public", path: "/models", public: true },
31 + { key: "security", path: "/security", public: true },
32 + { key: "login", path: "/login", public: true, loggedOut: true },
33 + { key: "signup", path: "/signup", public: true, loggedOut: true },
34 + { key: "chat", path: "/app/chat" },
35 + { key: "arena", path: "/app/arena" },
36 + { key: "scoreboard", path: "/app/arena/scoreboard" },
37 + { key: "models", path: "/app/models" },
38 + { key: "prompts", path: "/app/prompts" },
39 + { key: "projects", path: "/app/projects" },
40 + { key: "library", path: "/app/library" },
41 + { key: "usage", path: "/app/usage" },
42 + { key: "settings-account", path: "/app/settings/account" },
43 + { key: "settings-providers", path: "/app/settings/providers" },
44 + { key: "settings-endpoints", path: "/app/settings/endpoints" },
45 + { key: "onboarding", path: "/app/onboarding" },
46 +].filter((s) => !args.only || args.only.split(",").includes(s.key));
47 +
48 +const AUDIT = `(() => {
49 + const vw = window.innerWidth;
50 + const doc = document.documentElement;
51 + const out = { docOverflow: Math.max(doc.scrollWidth, document.body.scrollWidth) - vw, overflowing: [], smallTargets: [], clipped: [] };
52 + const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.opacity !== '0' && r.bottom > 0 && r.top < window.innerHeight; };
53 + const desc = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.getAttribute('aria-label') ? '[' + el.getAttribute('aria-label') + ']' : '') + ' "' + (el.textContent || '').trim().slice(0, 40).replace(/\\s+/g, ' ') + '"');
54 + for (const el of document.querySelectorAll('body *')) {
55 + if (!visible(el)) continue;
56 + const r = el.getBoundingClientRect();
57 + if (r.right > vw + 1 && r.left < vw && !el.closest('.snap-row, .marquee, [data-allow-overflow]')) out.overflowing.push({ el: desc(el), right: Math.round(r.right) });
58 + }
59 + const coarse = matchMedia('(pointer: coarse)').matches || vw < 768;
60 + if (coarse) {
61 + for (const el of document.querySelectorAll('button, a[href], [role="button"], input[type="checkbox"], input[type="radio"], [role="tab"], [role="switch"]')) {
62 + if (!visible(el)) continue;
63 + const r = el.getBoundingClientRect();
64 + const s = getComputedStyle(el);
65 + // .tap pseudo-element expands the hit area; count it as ok
66 + const hasTap = el.classList.contains('tap');
67 + if (!hasTap && (r.width < 40 || r.height < 40) && !(r.width >= 24 && r.height >= 24 && r.width * r.height >= 1400 && el.closest('nav,[role=tablist]'))) out.smallTargets.push({ el: desc(el), w: Math.round(r.width), h: Math.round(r.height) });
68 + }
69 + }
70 + for (const el of document.querySelectorAll('h1,h2,h3,p,span,a,button,td,th,li')) {
71 + if (!visible(el)) continue;
72 + const s = getComputedStyle(el);
73 + if (s.overflow === 'hidden' && s.textOverflow !== 'ellipsis' && el.scrollWidth > el.clientWidth + 2 && el.children.length === 0) out.clipped.push({ el: desc(el), sw: el.scrollWidth, cw: el.clientWidth });
74 + }
75 + out.overflowing = out.overflowing.slice(0, 12); out.smallTargets = out.smallTargets.slice(0, 20); out.clipped = out.clipped.slice(0, 12);
76 + return out;
77 +})()`;
78 +
79 +async function ensureSession(context) {
80 + const stateFile = "qa/.e2e-session.json";
81 + const email = process.env.QA_EMAIL;
82 + const password = process.env.QA_PASSWORD;
83 + const page = await context.newPage();
84 + await page.goto(`${BASE}/app/chat`, { waitUntil: "domcontentloaded" });
85 + if (/\/login/.test(page.url())) {
86 + if (!email || !password) throw new Error("Session expired: set QA_EMAIL and QA_PASSWORD to log in.");
87 + await page.getByLabel(/email/i).fill(email);
88 + await page.getByLabel(/^password$/i).fill(password);
89 + await page.getByRole("button", { name: /sign in/i }).click();
90 + await page.waitForURL(/\/app/, { timeout: 30_000 });
91 + await context.storageState({ path: stateFile });
92 + }
93 + await page.close();
94 +}
95 +
96 +const report = [];
97 +const browser = await chromium.launch();
98 +fs.mkdirSync(OUT, { recursive: true });
99 +const stateFile = fs.existsSync("qa/.e2e-session.json") ? "qa/.e2e-session.json" : undefined;
100 +
101 +for (const vp of VIEWPORTS) {
102 + const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: 2, isMobile: vp.mobile, hasTouch: vp.mobile, storageState: stateFile, colorScheme: args.dark ? "dark" : "light" });
103 + await ensureSession(context);
104 + for (const screen of SCREENS) {
105 + const page = await context.newPage();
106 + const errors = [];
107 + page.on("console", (m) => m.type() === "error" && !/favicon|Download the React DevTools|hydrat/i.test(m.text()) && errors.push(m.text().slice(0, 200)));
108 + page.on("pageerror", (e) => errors.push(e.message.slice(0, 200)));
109 + const t0 = Date.now();
110 + let status = "ok";
111 + try {
112 + const res = await page.goto(`${BASE}${screen.path}`, { waitUntil: "networkidle", timeout: 45_000 });
113 + if (!res || res.status() >= 400) status = `http ${res?.status()}`;
114 + await page.waitForTimeout(600);
115 + } catch (e) {
116 + status = `error: ${e.message.slice(0, 120)}`;
117 + }
118 + const dir = path.join(OUT, vp.name);
119 + fs.mkdirSync(dir, { recursive: true });
120 + const file = path.join(dir, `${screen.key}.png`);
121 + try {
122 + await page.screenshot({ path: file, fullPage: false });
123 + } catch {
124 + /* ignore */
125 + }
126 + let audit = null;
127 + try {
128 + audit = await page.evaluate(AUDIT);
129 + } catch {
130 + /* ignore */
131 + }
132 + const row = { viewport: vp.name, screen: screen.key, url: page.url().replace(BASE, ""), status, ms: Date.now() - t0, errors: errors.slice(0, 5), ...audit };
133 + report.push(row);
134 + const flags = [];
135 + if (audit?.docOverflow > 1) flags.push(`OVERFLOW +${audit.docOverflow}px`);
136 + if (audit?.overflowing?.length) flags.push(`${audit.overflowing.length} el. past edge`);
137 + if (audit?.smallTargets?.length) flags.push(`${audit.smallTargets.length} small targets`);
138 + if (audit?.clipped?.length) flags.push(`${audit.clipped.length} clipped`);
139 + if (errors.length) flags.push(`${errors.length} console errors`);
140 + console.log(`${vp.name.padEnd(8)} ${screen.key.padEnd(20)} ${status.padEnd(10)} ${String(row.ms).padStart(5)}ms ${flags.join(" · ")}`);
141 + await page.close();
142 + }
143 + await context.close();
144 +}
145 +await browser.close();
146 +fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify(report, null, 2));
147 +const bad = report.filter((r) => r.docOverflow > 1 || r.overflowing?.length || r.smallTargets?.length || r.clipped?.length || r.errors?.length || r.status !== "ok");
148 +console.log(`\n${report.length} screens audited, ${bad.length} with findings → qa/out/report.json`);
modified scripts/provider-matrix.ts +1 −0
@@ -26,6 +26,7 @@ const TEST_MODELS: Record<ProviderId, { text: string; reasoning: string; vision:
26 26 kimi: { text: "kimi-k2.6", reasoning: "kimi-k3", vision: "kimi-k2.6" },
27 27 openrouter: { text: "openai/gpt-5.4-nano", reasoning: "openai/gpt-5.4-nano", vision: "openai/gpt-5.4-nano" },
28 28 cerebras: { text: "gemma-4-31b", reasoning: "gpt-oss-120b", vision: "gemma-4-31b" },
29 + custom: { text: "", reasoning: "", vision: "" }, // per-user endpoints — never has an owner key, always skipped
29 30 };
30 31
31 32 // 2x2 PNG (red, green / blue, white) — 32x32 needed for xAI minimums, so we scale via a bigger canvas.
modified src/app/(auth)/_components/auth-card.tsx +11 −7
@@ -1,16 +1,20 @@
1 1 import * as React from "react";
2 2 import { cn } from "@/lib/utils";
3 3
4 +/**
5 + * Auth form container. On phones it is borderless and fills the column (full-height flow, big
6 + * title, 16 px inputs from the base styles); from `sm` up it becomes an elevated card.
7 + */
4 8 export function AuthCard({ title, description, children, footer, icon, className }: { title: string; description?: React.ReactNode; children: React.ReactNode; footer?: React.ReactNode; icon?: React.ReactNode; className?: string }) {
5 9 return (
6 10 <div className={cn("animate-fade-up", className)}>
7 − <div className="rounded-2xl border border-border bg-bg-elevated p-6 shadow-md sm:p-8">
11 + <div className="rounded-2xl sm:border sm:border-border sm:bg-bg-elevated sm:p-8 sm:shadow-md">
8 12 {icon ? <div className="mb-4 flex size-11 items-center justify-center rounded-xl bg-accent-soft text-accent [&_svg]:size-5">{icon}</div> : null}
9 − <h1 className="text-[22px] font-semibold leading-tight tracking-tight">{title}</h1>
10 − {description ? <p className="mt-1.5 text-[14px] leading-6 text-fg-muted">{description}</p> : null}
11 − <div className="mt-6">{children}</div>
13 + <h1 className="text-balance text-[26px] font-semibold leading-tight tracking-[-0.02em] sm:text-[22px]">{title}</h1>
14 + {description ? <p className="mt-2 text-pretty text-[15px] leading-6 text-fg-muted sm:mt-1.5 sm:text-[14px]">{description}</p> : null}
15 + <div className="mt-7 sm:mt-6 [&_button[type=submit]]:h-12 sm:[&_button[type=submit]]:h-11 [&_input]:h-11 sm:[&_input]:h-9 [&_label]:text-[14px] sm:[&_label]:text-[13px]">{children}</div>
12 16 </div>
13 − {footer ? <div className="mt-5 text-center text-sm text-fg-muted">{footer}</div> : null}
17 + {footer ? <div className="mt-6 text-center text-[15px] text-fg-muted sm:mt-5 sm:text-sm">{footer}</div> : null}
14 18 </div>
15 19 );
16 20 }
@@ -18,7 +22,7 @@ export function AuthCard({ title, description, children, footer, icon, className
18 22 export function FormError({ children, id }: { children?: React.ReactNode; id?: string }) {
19 23 if (!children) return null;
20 24 return (
21 − <p id={id} role="alert" className="rounded-md border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] leading-5 text-danger">
25 + <p id={id} role="alert" className="rounded-lg border border-danger/30 bg-danger-soft px-3 py-2.5 text-[14px] leading-5 text-danger sm:text-[13px]">
22 26 {children}
23 27 </p>
24 28 );
@@ -27,7 +31,7 @@ export function FormError({ children, id }: { children?: React.ReactNode; id?: s
27 31 export function FormNotice({ children, tone = "info" }: { children: React.ReactNode; tone?: "info" | "success" | "warning" }) {
28 32 const cls = { info: "border-info/30 bg-info-soft text-info", success: "border-success/30 bg-success-soft text-success", warning: "border-warning/30 bg-warning-soft text-warning" }[tone];
29 33 return (
30 − <div role="status" className={cn("rounded-md border px-3 py-2 text-[13px] leading-5", cls)}>
34 + <div role="status" className={cn("rounded-lg border px-3 py-2.5 text-[14px] leading-5 sm:text-[13px]", cls)}>
31 35 {children}
32 36 </div>
33 37 );
modified src/app/(auth)/layout.tsx +78 −23
@@ -1,32 +1,87 @@
1 1 import Link from "next/link";
2 −import { Logo } from "@/components/brand/logo";
2 +import { Check } from "lucide-react";
3 +import { Logo, LogoMark, Wordmark } from "@/components/brand/logo";
4 +import { ProviderIcon } from "@/components/brand/provider-icon";
3 5 import { ThemeToggle } from "@/components/marketing/theme-toggle";
6 +import { PROVIDER_ORDER } from "@/lib/client/providers";
4 7
8 +const POINTS = ["Bring your own keys — encrypted with AES-256-GCM, never sent to the browser", "Chat, Arena, model catalog, projects and usage analytics in one workspace", "Free to use; you pay providers directly at list price"];
9 +
10 +/**
11 + * Auth shell. Phones: full-height, brand mark on top, the form fills the width, safe areas
12 + * respected. Desktop (≥ lg): a quiet brand panel on the left, the form centered on the right.
13 + */
5 14 export default function AuthLayout({ children }: { children: React.ReactNode }) {
6 15 return (
7 − <div className="relative flex min-h-dvh flex-col overflow-x-clip">
8 − <div aria-hidden className="dot-grid pointer-events-none absolute inset-0 opacity-60 [mask-image:radial-gradient(ellipse_80%_55%_at_50%_0%,black_20%,transparent_100%)] dark:opacity-40" />
9 − <header className="relative z-10 flex h-14 items-center justify-between px-5 sm:px-8">
10 − <Link href="/" className="rounded-md" aria-label="PolyLLM home">
11 − <Logo size={24} />
12 − </Link>
13 − <ThemeToggle />
14 − </header>
15 − <main className="relative z-10 flex flex-1 items-start justify-center px-4 pb-10 pt-6 sm:items-center sm:pt-0">
16 − <div className="w-full max-w-[400px]">{children}</div>
17 − </main>
18 − <footer className="relative z-10 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 px-4 pb-[calc(env(safe-area-inset-bottom)+20px)] text-xs text-fg-subtle">
19 − <span>© {new Date().getFullYear()} PolyLLM</span>
20 − <Link href="/privacy" className="hover:text-fg">
21 − Privacy
22 − </Link>
23 − <Link href="/terms" className="hover:text-fg">
24 − Terms
16 + <div className="relative flex min-h-dvh flex-col overflow-x-clip lg:grid lg:grid-cols-[1.05fr_1fr]">
17 + {/* Brand panel — desktop only */}
18 + <aside className="relative hidden flex-col justify-between overflow-hidden border-r border-border bg-bg-subtle px-12 py-10 lg:flex" aria-label="About PolyLLM">
19 + <div aria-hidden className="dot-grid pointer-events-none absolute inset-0 opacity-70 [mask-image:radial-gradient(ellipse_70%_60%_at_30%_20%,black_20%,transparent_100%)] dark:opacity-40" />
20 + <div aria-hidden className="pointer-events-none absolute -left-24 top-1/3 h-[24rem] w-[30rem] rounded-full bg-accent/15 blur-[110px] dark:bg-accent/10" />
21 + <Link href="/" className="relative inline-flex w-fit rounded-md" aria-label="PolyLLM home">
22 + <Logo size={26} />
25 23 </Link>
26 − <Link href="/#faq" className="hover:text-fg">
27 − Help
28 − </Link>
29 − </footer>
24 + <div className="relative max-w-md">
25 + <h2 className="text-balance text-4xl font-semibold tracking-[-0.03em]">
26 + One interface.
27 + <br />
28 + <span className="text-gradient">Every model.</span>
29 + </h2>
30 + <ul className="mt-7 space-y-3">
31 + {POINTS.map((p) => (
32 + <li key={p} className="flex items-start gap-2.5 text-[15px] leading-6 text-fg-muted">
33 + <span className="mt-1 flex size-4 shrink-0 items-center justify-center rounded-full bg-success-soft text-success">
34 + <Check className="size-2.5" strokeWidth={3} />
35 + </span>
36 + {p}
37 + </li>
38 + ))}
39 + </ul>
40 + <ul className="mt-8 flex flex-wrap gap-x-4 gap-y-2" aria-label="Supported providers">
41 + {PROVIDER_ORDER.map((p) => (
42 + <li key={p} className="inline-flex items-center text-fg-subtle">
43 + <ProviderIcon provider={p} size={16} />
44 + </li>
45 + ))}
46 + </ul>
47 + </div>
48 + <p className="relative text-xs text-fg-subtle">Hosted on MacLustr · Made by Simon-Pierre Boucher</p>
49 + </aside>
50 +
51 + {/* Form column */}
52 + <div className="relative flex min-h-dvh flex-col lg:min-h-0">
53 + <div aria-hidden className="dot-grid pointer-events-none absolute inset-0 opacity-60 [mask-image:radial-gradient(ellipse_80%_55%_at_50%_0%,black_20%,transparent_100%)] dark:opacity-40 lg:hidden" />
54 + <header className="relative z-10 flex h-14 shrink-0 items-center justify-between px-5 pt-safe sm:px-8">
55 + <Link href="/" className="rounded-md lg:invisible" aria-label="PolyLLM home">
56 + <Logo size={24} />
57 + </Link>
58 + <ThemeToggle />
59 + </header>
60 + <main className="relative z-10 flex flex-1 flex-col justify-start px-4 pb-8 pt-2 sm:justify-center sm:px-8 sm:pb-12 sm:pt-0">
61 + <div className="mx-auto w-full max-w-[420px]">
62 + <div className="mb-6 flex items-center gap-2.5 sm:hidden">
63 + <LogoMark size={36} className="shadow-md" />
64 + <Wordmark className="text-[20px]" />
65 + </div>
66 + {children}
67 + </div>
68 + </main>
69 + <footer className="relative z-10 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 px-4 pb-[max(20px,var(--sab))] text-xs text-fg-subtle">
70 + <span>© {new Date().getFullYear()} PolyLLM</span>
71 + <Link href="/privacy" className="inline-flex min-h-[28px] items-center hover:text-fg">
72 + Privacy
73 + </Link>
74 + <Link href="/terms" className="inline-flex min-h-[28px] items-center hover:text-fg">
75 + Terms
76 + </Link>
77 + <Link href="/security" className="inline-flex min-h-[28px] items-center hover:text-fg">
78 + Security
79 + </Link>
80 + <Link href="/contact" className="inline-flex min-h-[28px] items-center hover:text-fg">
81 + Help
82 + </Link>
83 + </footer>
84 + </div>
30 85 </div>
31 86 );
32 87 }
added src/app/(marketing)/compare/[slug]/page.tsx +120 −0
@@ -0,0 +1,120 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { ArrowRight, Swords } from "lucide-react";
5 +import { Container, Eyebrow } from "@/components/marketing/section";
6 +import { Button } from "@/components/ui/button";
7 +import { ProviderIcon } from "@/components/brand/provider-icon";
8 +import { CompareTable } from "@/components/models/compare-table";
9 +import { PROVIDERS } from "@/lib/client/providers";
10 +import { getPublicModels, flagshipModels } from "@/lib/models/public-data";
11 +import { resolveCompareSlug, compareSlug, parseCompareSlug } from "@/lib/models/slug";
12 +import { formatContext, formatPrice } from "@/lib/models/format";
13 +
14 +export const dynamic = "force-dynamic";
15 +
16 +type Params = Promise<{ slug: string }>;
17 +
18 +async function resolve(slug: string) {
19 + if (!parseCompareSlug(slug)) return null;
20 + const { models, ctx } = await getPublicModels();
21 + const chosen = resolveCompareSlug(models, slug);
22 + return chosen ? { chosen, models, ctx } : null;
23 +}
24 +
25 +function names(models: { displayName: string }[]): string {
26 + return models.map((m) => m.displayName).join(" vs ");
27 +}
28 +
29 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
30 + const { slug } = await params;
31 + const r = await resolve(slug);
32 + if (!r) return { title: "Comparison not found", robots: { index: false } };
33 + const title = `${names(r.chosen)} — pricing, context & capabilities`;
34 + const description = r.chosen.map((m) => `${m.displayName} (${PROVIDERS[m.provider].shortName}): ${formatPrice(m.pricing?.inputPerMillion)} in / ${formatPrice(m.pricing?.outputPerMillion)} out per 1M tokens, ${formatContext(m.limits?.contextTokens)} context`).join(" · ") + ". Compared side by side on PolyLLM.";
35 + const canonical = `/compare/${compareSlug(r.chosen)}`;
36 + return { title, description, alternates: { canonical }, openGraph: { title: `${names(r.chosen)} — PolyLLM`, description, url: canonical } };
37 +}
38 +
39 +export default async function ComparePage({ params }: { params: Params }) {
40 + const { slug } = await params;
41 + const r = await resolve(slug);
42 + if (!r) notFound();
43 + const { chosen, models, ctx } = r;
44 + const arenaHref = `/app/arena?models=${encodeURIComponent(chosen.map((m) => m.key).join(","))}`;
45 + const chosenKeys = new Set(chosen.map((m) => m.key));
46 + const related = flagshipModels(models, 6)
47 + .filter((f) => !chosenKeys.has(f.key))
48 + .slice(0, 4)
49 + .map((f) => ({ f, slug: compareSlug([chosen[0], f]) }));
50 +
51 + return (
52 + <>
53 + <section className="border-b border-border bg-bg-subtle/60 py-12 sm:py-16">
54 + <Container>
55 + <Eyebrow>Model comparison</Eyebrow>
56 + <h1 className="mt-3 max-w-3xl text-balance text-3xl font-semibold leading-[1.08] tracking-tight sm:text-5xl">
57 + {chosen.map((m, i) => (
58 + <span key={m.key}>
59 + {i > 0 ? <span className="text-fg-subtle"> vs </span> : null}
60 + {m.displayName}
61 + </span>
62 + ))}
63 + </h1>
64 + <p className="mt-4 max-w-2xl text-pretty text-[15px] leading-7 text-fg-muted sm:text-base">
65 + {chosen.map((m) => `${PROVIDERS[m.provider].name}’s ${m.displayName}`).join(" and ")} compared on price per million tokens, context window, output limits, reasoning controls and every capability PolyLLM tracks — from the providers’ own listings, nothing guessed.
66 + </p>
67 + <div className="mt-6 flex flex-col gap-2 sm:flex-row">
68 + <Button asChild size="lg" className="w-full sm:w-auto">
69 + <Link href={arenaHref}>
70 + <Swords /> Try {chosen.length === 2 ? "both" : "all"} in Arena
71 + </Link>
72 + </Button>
73 + <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">
74 + <Link href="/models">Browse the full catalog</Link>
75 + </Button>
76 + </div>
77 + </Container>
78 + </section>
79 +
80 + <Container className="py-10 sm:py-14">
81 + <CompareTable models={chosen} ctx={ctx} />
82 +
83 + {related.length ? (
84 + <section className="mt-16">
85 + <h2 className="text-lg font-semibold tracking-tight">More comparisons with {chosen[0].displayName}</h2>
86 + <ul className="mt-4 grid gap-2 sm:grid-cols-2">
87 + {related.map(({ f, slug: s }) => (
88 + <li key={s}>
89 + <Link href={`/compare/${s}`} className="panel flex items-center gap-3 p-3 transition-colors hover:bg-bg-muted">
90 + <ProviderIcon provider={f.provider} size={16} />
91 + <span className="min-w-0 flex-1 truncate text-[13.5px] font-medium">
92 + {chosen[0].displayName} <span className="text-fg-subtle">vs</span> {f.displayName}
93 + </span>
94 + <ArrowRight className="size-4 shrink-0 text-fg-subtle" />
95 + </Link>
96 + </li>
97 + ))}
98 + </ul>
99 + </section>
100 + ) : null}
101 +
102 + <section className="mt-16 rounded-2xl border border-border bg-bg-subtle/60 p-6 text-center sm:p-10">
103 + <h2 className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">Run the same prompt on {chosen.length === 2 ? "both" : "all of them"}.</h2>
104 + <p className="mx-auto mt-3 max-w-md text-[15px] leading-7 text-fg-muted">PolyLLM Arena streams the answers side by side with latency, tokens and cost — with your own API keys, encrypted.</p>
105 + <div className="mt-6 flex flex-col justify-center gap-2 sm:flex-row">
106 + <Button asChild size="lg" className="w-full sm:w-auto">
107 + <Link href="/signup">
108 + Create a free account <ArrowRight />
109 + </Link>
110 + </Button>
111 + <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">
112 + <Link href={arenaHref}>Open Arena</Link>
113 + </Button>
114 + </div>
115 + <p className="mt-6 text-[11px] leading-5 text-fg-subtle">Prices are the providers’ published list prices in USD per million tokens at the last catalog audit. Release dates and knowledge cutoffs appear only when the provider publishes them.</p>
116 + </section>
117 + </Container>
118 + </>
119 + );
120 +}
added src/app/(marketing)/contact/page.tsx +93 −0
@@ -0,0 +1,93 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { ArrowRight, ExternalLink, Mail, Server, ShieldCheck, UserRound } from "lucide-react";
4 +import { Button } from "@/components/ui/button";
5 +import { Container, Eyebrow } from "@/components/marketing/section";
6 +import { Reveal } from "@/components/marketing/reveal";
7 +
8 +export const metadata: Metadata = {
9 + title: "Contact",
10 + description: "How to reach the person behind PolyLLM, where it is hosted, and how to report a security issue.",
11 + alternates: { canonical: "/contact" },
12 +};
13 +
14 +const CARDS = [
15 + {
16 + icon: <Mail />,
17 + title: "E-mail",
18 + body: "For questions, bug reports, feature requests or anything about your account.",
19 + action: (
20 + <a href="mailto:contact@spboucher.ai" className="inline-flex items-center gap-1.5 font-medium text-accent underline-offset-4 hover:underline">
21 + contact@spboucher.ai <ArrowRight className="size-4" aria-hidden />
22 + </a>
23 + ),
24 + },
25 + {
26 + icon: <UserRound />,
27 + title: "Who built it",
28 + body: "PolyLLM is designed, built and operated by Simon-Pierre Boucher. One person, so replies are personal but not instant — expect a day or two.",
29 + action: <span className="text-fg">Simon-Pierre Boucher</span>,
30 + },
31 + {
32 + icon: <Server />,
33 + title: "Where it runs",
34 + body: "PolyLLM is hosted on MacLustr, a private cluster of Apple silicon Macs in Québec, fronted by a dedicated gateway. Your data stays in PolyLLM's own PostgreSQL database.",
35 + action: (
36 + <a href="https://www.maclustr.io" target="_blank" rel="noreferrer noopener" className="inline-flex items-center gap-1.5 font-medium text-accent underline-offset-4 hover:underline">
37 + www.maclustr.io <ExternalLink className="size-3.5" aria-hidden />
38 + </a>
39 + ),
40 + },
41 + {
42 + icon: <ShieldCheck />,
43 + title: "Security reports",
44 + body: "Found a vulnerability? Write to the same address with “security” in the subject. Please allow a reasonable window before disclosing publicly.",
45 + action: (
46 + <Link href="/security" className="inline-flex items-center gap-1.5 font-medium text-accent underline-offset-4 hover:underline">
47 + Read the security page <ArrowRight className="size-4" aria-hidden />
48 + </Link>
49 + ),
50 + },
51 +];
52 +
53 +export default function ContactPage() {
54 + return (
55 + <Container className="py-12 sm:py-20">
56 + <Reveal className="max-w-2xl">
57 + <Eyebrow>Contact</Eyebrow>
58 + <h1 className="mt-3 text-balance text-4xl font-semibold tracking-[-0.03em]">Talk to a person.</h1>
59 + <p className="mt-4 text-pretty text-base leading-7 text-fg-muted sm:text-lg sm:leading-8">No ticketing system, no chatbot. One e-mail address, read by the person who wrote the code.</p>
60 + <Button asChild size="lg" className="mt-7 w-full sm:w-auto">
61 + <a href="mailto:contact@spboucher.ai">
62 + <Mail /> contact@spboucher.ai
63 + </a>
64 + </Button>
65 + </Reveal>
66 + <ul className="mt-12 grid gap-x-10 gap-y-9 sm:grid-cols-2">
67 + {CARDS.map((c, i) => (
68 + <Reveal key={c.title} as="li" delay={0.04 * i} className="flex gap-3.5">
69 + <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent [&_svg]:size-5">{c.icon}</span>
70 + <div className="min-w-0">
71 + <h2 className="text-[16px] font-semibold tracking-tight">{c.title}</h2>
72 + <p className="mt-1 text-[14.5px] leading-6 text-fg-muted">{c.body}</p>
73 + <p className="mt-2.5 text-[14px]">{c.action}</p>
74 + </div>
75 + </Reveal>
76 + ))}
77 + </ul>
78 + <Reveal delay={0.15} className="mt-14 rounded-2xl bg-bg-subtle p-5 sm:p-6">
79 + <p className="text-[13px] leading-6 text-fg-muted">
80 + Provider issues (billing, quotas, a model that misbehaves) are handled by the provider — OpenAI, Anthropic, Google, xAI, Mistral, DeepSeek, Moonshot AI, OpenRouter or Cerebras — under your own account with them. PolyLLM never resells model access. See the{" "}
81 + <Link href="/privacy" className="text-accent underline-offset-4 hover:underline">
82 + privacy policy
83 + </Link>{" "}
84 + and{" "}
85 + <Link href="/terms" className="text-accent underline-offset-4 hover:underline">
86 + terms
87 + </Link>
88 + .
89 + </p>
90 + </Reveal>
91 + </Container>
92 + );
93 +}
added src/app/(marketing)/models/page.tsx +228 −0
@@ -0,0 +1,228 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { ArrowRight, Check, Minus } from "lucide-react";
4 +import { Container, Eyebrow } from "@/components/marketing/section";
5 +import { Button } from "@/components/ui/button";
6 +import { ProviderIcon } from "@/components/brand/provider-icon";
7 +import { BadgeChips, LifecycleChip } from "@/components/models/badge-chips";
8 +import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers";
9 +import { getPublicModels, popularComparisons } from "@/lib/models/public-data";
10 +import { deriveBadges, lifecycleStatus, sortWeightOf } from "@/lib/models/badges";
11 +import { formatContext, formatPrice } from "@/lib/models/format";
12 +import type { PolyModel } from "@/lib/ai/core/types";
13 +import type { BadgeContext } from "@/lib/models/badges";
14 +
15 +export const dynamic = "force-dynamic";
16 +
17 +export const metadata: Metadata = {
18 + title: "AI model catalog — pricing, context windows and capabilities",
19 + description: "Every model PolyLLM supports across OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras: input/output prices per million tokens, context windows, reasoning, vision, tools and lifecycle status — updated from the providers’ own listings.",
20 + alternates: { canonical: "/models" },
21 + openGraph: { title: "AI model catalog — PolyLLM", description: "Prices, context windows and capabilities for every supported model.", url: "/models" },
22 +};
23 +
24 +function Mark({ on }: { on: boolean }) {
25 + return on ? <Check className="mx-auto size-4 text-success" aria-label="yes" /> : <Minus className="mx-auto size-4 text-border-strong" aria-label="no" />;
26 +}
27 +
28 +export default async function PublicModelsPage() {
29 + const { models, ctx, ok } = await getPublicModels();
30 + const visible = models.filter((m) => m.status !== "deprecated");
31 + const groups = PROVIDER_ORDER.map((p) => ({ provider: p, list: visible.filter((m) => m.provider === p).sort((a, b) => sortWeightOf(b) - sortWeightOf(a) || a.displayName.localeCompare(b.displayName)) })).filter((g) => g.list.length);
32 + const popular = popularComparisons(models);
33 +
34 + return (
35 + <>
36 + <section className="border-b border-border bg-bg-subtle/60 py-12 sm:py-16">
37 + <Container>
38 + <Eyebrow>Model catalog</Eyebrow>
39 + <h1 className="mt-3 max-w-3xl text-balance text-3xl font-semibold leading-[1.08] tracking-tight sm:text-5xl">Every model. One honest table.</h1>
40 + <p className="mt-4 max-w-2xl text-pretty text-[15px] leading-7 text-fg-muted sm:text-base">
41 + {visible.length ? `${visible.length} models from ${groups.length} providers` : "Models from nine providers"} — prices per million tokens, context windows, reasoning, vision, tools and lifecycle status, merged from each provider’s live listing and documented catalogs. Bring your own keys and use any of them in one workspace.
42 + </p>
43 + <div className="mt-6 flex flex-col gap-2 sm:flex-row">
44 + <Button asChild size="lg" className="w-full sm:w-auto">
45 + <Link href="/signup">
46 + Start using PolyLLM <ArrowRight />
47 + </Link>
48 + </Button>
49 + {popular[0] ? (
50 + <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">
51 + <Link href={`/compare/${popular[0].slug}`}>
52 + Compare {popular[0].a.displayName} vs {popular[0].b.displayName}
53 + </Link>
54 + </Button>
55 + ) : null}
56 + </div>
57 + </Container>
58 + </section>
59 +
60 + <Container className="py-10 sm:py-14">
61 + {!ok || visible.length === 0 ? (
62 + <div className="rounded-xl border border-dashed border-border px-6 py-12 text-center">
63 + <p className="text-sm font-medium">{ok ? "The catalog is being populated." : "The catalog is temporarily unavailable."}</p>
64 + <p className="mt-1 text-[13px] text-fg-muted">Please check back in a moment.</p>
65 + </div>
66 + ) : (
67 + <div className="space-y-12">
68 + {groups.map((g) => (
69 + <section key={g.provider} id={g.provider} aria-labelledby={`h-${g.provider}`} className="scroll-mt-20">
70 + <div className="flex items-center gap-3">
71 + <span className="flex size-9 items-center justify-center rounded-lg bg-bg-subtle">
72 + <ProviderIcon provider={g.provider} size={18} />
73 + </span>
74 + <div>
75 + <h2 id={`h-${g.provider}`} className="text-lg font-semibold tracking-tight">
76 + {PROVIDERS[g.provider].name}
77 + </h2>
78 + <p className="text-[12.5px] text-fg-muted">
79 + {g.list.length} model{g.list.length === 1 ? "" : "s"} · {PROVIDERS[g.provider].description}
80 + </p>
81 + </div>
82 + </div>
83 +
84 + {/* Desktop table */}
85 + <div className="mt-4 hidden overflow-hidden rounded-xl border border-border md:block">
86 + <table className="w-full text-[13px]">
87 + <thead>
88 + <tr className="border-b border-border bg-bg-subtle/60 text-left text-[11px] font-medium uppercase tracking-wide text-fg-subtle">
89 + <th className="px-3 py-2">Model</th>
90 + <th className="px-3 py-2 text-right">Input $/1M</th>
91 + <th className="px-3 py-2 text-right">Output $/1M</th>
92 + <th className="px-3 py-2 text-right">Context</th>
93 + <th className="px-3 py-2 text-right">Max out</th>
94 + <th className="px-3 py-2 text-center">Reasoning</th>
95 + <th className="px-3 py-2 text-center">Vision</th>
96 + <th className="px-3 py-2 text-center">Tools</th>
97 + <th className="px-3 py-2">Status</th>
98 + </tr>
99 + </thead>
100 + <tbody>
101 + {g.list.map((m) => (
102 + <tr key={m.key} className="border-b border-hairline last:border-b-0">
103 + <td className="px-3 py-2">
104 + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
105 + <span className="font-medium">{m.displayName}</span>
106 + <BadgeChips badges={deriveBadges(m, ctx)} max={3} size="xs" />
107 + </div>
108 + <div className="font-mono text-[11px] text-fg-subtle">{m.id}</div>
109 + </td>
110 + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatPrice(m.pricing?.inputPerMillion)}</td>
111 + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatPrice(m.pricing?.outputPerMillion)}</td>
112 + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatContext(m.limits?.contextTokens)}</td>
113 + <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatContext(m.limits?.maxOutputTokens)}</td>
114 + <td className="px-3 py-2">
115 + <Mark on={m.capabilities.reasoning} />
116 + </td>
117 + <td className="px-3 py-2">
118 + <Mark on={m.capabilities.vision} />
119 + </td>
120 + <td className="px-3 py-2">
121 + <Mark on={m.capabilities.tools} />
122 + </td>
123 + <td className="px-3 py-2">
124 + <LifecycleChip lifecycle={lifecycleStatus(m, { ctx })} />
125 + </td>
126 + </tr>
127 + ))}
128 + </tbody>
129 + </table>
130 + </div>
131 +
132 + {/* Mobile rows */}
133 + <ul className="mt-4 space-y-2 md:hidden">
134 + {g.list.map((m) => (
135 + <MobileRow key={m.key} m={m} ctx={ctx} />
136 + ))}
137 + </ul>
138 + </section>
139 + ))}
140 + </div>
141 + )}
142 +
143 + {popular.length ? (
144 + <section className="mt-16">
145 + <Eyebrow>Popular comparisons</Eyebrow>
146 + <h2 className="mt-3 text-2xl font-semibold tracking-tight">Head to head</h2>
147 + <ul className="mt-5 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
148 + {popular.map((c) => (
149 + <li key={c.slug}>
150 + <Link href={`/compare/${c.slug}`} className="panel flex items-center gap-3 p-3 transition-colors hover:bg-bg-muted">
151 + <span className="flex -space-x-1">
152 + <span className="flex size-8 items-center justify-center rounded-full bg-bg-elevated ring-2 ring-bg">
153 + <ProviderIcon provider={c.a.provider} size={14} />
154 + </span>
155 + <span className="flex size-8 items-center justify-center rounded-full bg-bg-elevated ring-2 ring-bg">
156 + <ProviderIcon provider={c.b.provider} size={14} />
157 + </span>
158 + </span>
159 + <span className="min-w-0 flex-1 truncate text-[13.5px] font-medium">
160 + {c.a.displayName} <span className="text-fg-subtle">vs</span> {c.b.displayName}
161 + </span>
162 + <ArrowRight className="size-4 shrink-0 text-fg-subtle" />
163 + </Link>
164 + </li>
165 + ))}
166 + </ul>
167 + </section>
168 + ) : null}
169 +
170 + <section className="mt-16 rounded-2xl border border-border bg-bg-subtle/60 p-6 text-center sm:p-10">
171 + <h2 className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">Use any of them with your own keys.</h2>
172 + <p className="mx-auto mt-3 max-w-md text-[15px] leading-7 text-fg-muted">Free interface, encrypted keys, real streaming, side-by-side Arena and usage analytics.</p>
173 + <div className="mt-6 flex flex-col justify-center gap-2 sm:flex-row">
174 + <Button asChild size="lg" className="w-full sm:w-auto">
175 + <Link href="/signup">
176 + Create a free account <ArrowRight />
177 + </Link>
178 + </Button>
179 + <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">
180 + <Link href="/login">Sign in</Link>
181 + </Button>
182 + </div>
183 + <p className="mt-6 text-[11px] leading-5 text-fg-subtle">Prices are the providers’ published list prices in USD per million tokens at the last catalog audit; verify with the provider before relying on them. Model names are trademarks of their owners.</p>
184 + </section>
185 + </Container>
186 + </>
187 + );
188 +}
189 +
190 +function MobileRow({ m, ctx: c }: { m: PolyModel; ctx: BadgeContext }) {
191 + return (
192 + <li className="panel p-3">
193 + <div className="flex items-start gap-2.5">
194 + <ProviderIcon provider={m.provider} size={18} className="mt-0.5 shrink-0" />
195 + <div className="min-w-0 flex-1">
196 + <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
197 + <span className="text-[14px] font-medium">{m.displayName}</span>
198 + <LifecycleChip lifecycle={lifecycleStatus(m, { ctx: c })} />
199 + </div>
200 + <div className="truncate font-mono text-[11px] text-fg-subtle">{m.id}</div>
201 + <dl className="mt-2 grid grid-cols-3 gap-2 text-[12px]">
202 + <div>
203 + <dt className="text-[10.5px] text-fg-subtle">In / Out $/1M</dt>
204 + <dd className="font-mono tabular-nums">
205 + {formatPrice(m.pricing?.inputPerMillion)} / {formatPrice(m.pricing?.outputPerMillion)}
206 + </dd>
207 + </div>
208 + <div>
209 + <dt className="text-[10.5px] text-fg-subtle">Context</dt>
210 + <dd className="font-mono tabular-nums">{formatContext(m.limits?.contextTokens)}</dd>
211 + </div>
212 + <div>
213 + <dt className="text-[10.5px] text-fg-subtle">Max out</dt>
214 + <dd className="font-mono tabular-nums">{formatContext(m.limits?.maxOutputTokens)}</dd>
215 + </div>
216 + </dl>
217 + <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted">
218 + <span className={m.capabilities.reasoning ? "" : "line-through text-fg-subtle"}>Reasoning</span>
219 + <span className={m.capabilities.vision ? "" : "line-through text-fg-subtle"}>Vision</span>
220 + <span className={m.capabilities.tools ? "" : "line-through text-fg-subtle"}>Tools</span>
221 + <span className={m.capabilities.structuredOutput ? "" : "line-through text-fg-subtle"}>JSON</span>
222 + </div>
223 + <BadgeChips badges={deriveBadges(m, c)} max={4} size="xs" className="mt-2" />
224 + </div>
225 + </div>
226 + </li>
227 + );
228 +}
modified src/app/(marketing)/page.tsx +12 −8
@@ -1,20 +1,21 @@
1 1 import type { Metadata } from "next";
2 2 import { Hero } from "@/components/marketing/hero";
3 −import { ProvidersRow } from "@/components/marketing/providers-row";
4 −import { FeatureUnified, FeatureByok, FeatureArena, FeatureSecurity, FeatureAnalytics } from "@/components/marketing/features";
3 +import { ModelStrip } from "@/components/marketing/model-strip";
4 +import { FeatureRouter, FeatureArena, FeatureCatalog, FeatureWorkspace, FeatureAnalytics, FeatureEndpoints, FeatureAlso } from "@/components/marketing/features";
5 5 import { FeatureConfig } from "@/components/marketing/feature-config";
6 6 import { InteractiveDemo } from "@/components/marketing/demo";
7 +import { SecurityTeaser } from "@/components/marketing/security-teaser";
7 8 import { Faq } from "@/components/marketing/faq";
8 9 import { FinalCta } from "@/components/marketing/final-cta";
9 10
10 11 export const metadata: Metadata = {
11 12 title: { absolute: "PolyLLM — One interface. Every model." },
12 13 description:
13 − "PolyLLM is a free, bring-your-own-keys workspace for OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras models: real streaming chat, capability-driven configuration, side-by-side Arena and usage analytics. Your keys are encrypted and never leave the server.",
14 + "PolyLLM is a free, bring-your-own-keys workspace for OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter, Cerebras and your own local models: Smart Router, real streaming chat, side-by-side Arena, model catalog, projects, usage analytics. Keys encrypted with AES-256-GCM and never sent to the browser.",
14 15 alternates: { canonical: "/" },
15 16 openGraph: {
16 17 title: "PolyLLM — One interface. Every model.",
17 − description: "Your models. Your keys. One workspace.",
18 + description: "Bring your own keys. Compare models. Control every parameter. Track every token.",
18 19 url: "/",
19 20 },
20 21 };
@@ -23,14 +24,17 @@ export default function LandingPage() {
23 24 return (
24 25 <>
25 26 <Hero />
26 − <ProvidersRow />
27 − <FeatureUnified />
28 − <FeatureByok />
27 + <ModelStrip />
28 + <FeatureRouter />
29 29 <FeatureArena />
30 + <FeatureCatalog />
30 31 <FeatureConfig />
31 − <FeatureSecurity />
32 + <FeatureWorkspace />
32 33 <FeatureAnalytics />
34 + <FeatureEndpoints />
33 35 <InteractiveDemo />
36 + <FeatureAlso />
37 + <SecurityTeaser />
34 38 <Faq />
35 39 <FinalCta />
36 40 </>
added src/app/(marketing)/security/page.tsx +254 −0
@@ -0,0 +1,254 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { ArrowRight, Cookie, Fingerprint, Gauge, KeyRound, ScrollText, ShieldCheck } from "lucide-react";
4 +import { Button } from "@/components/ui/button";
5 +import { Container, Eyebrow, Section, SectionHeading } from "@/components/marketing/section";
6 +import { Reveal } from "@/components/marketing/reveal";
7 +import { KeyLifecycle, SecurityFlow } from "@/components/marketing/security-flow";
8 +
9 +export const metadata: Metadata = {
10 + title: "Security",
11 + description: "How PolyLLM protects your API keys and account: AES-256-GCM key encryption with HKDF-derived keys, Argon2id passwords, HttpOnly cookies, a strict Content-Security-Policy, per-route rate limits and an audit log. Every claim on this page is verified in the source code.",
12 + alternates: { canonical: "/security" },
13 + openGraph: { title: "PolyLLM — Security", description: "Where your keys go, how they are encrypted, and what we log.", url: "/security" },
14 +};
15 +
16 +/* ------------------------------------------------------------------------------------------------
17 + * Facts verified in code (see docs/upgrade-notes/F-marketing.md for the file references)
18 + * ---------------------------------------------------------------------------------------------- */
19 +const AUTH_LIMITS: [string, string][] = [
20 + ["Sign in", "8 / min"],
21 + ["Sign up", "4 / min"],
22 + ["Password reset request", "4 / min"],
23 + ["Verification e-mail", "3 / min"],
24 + ["Change password", "5 / min"],
25 + ["Change e-mail", "3 / min"],
26 + ["Delete account", "3 / min"],
27 + ["Any other auth route", "100 / min"],
28 +];
29 +const APP_LIMITS: [string, string][] = [
30 + ["Chat requests", "60 / min"],
31 + ["Arena sessions", "20 / min"],
32 + ["Arena streams", "80 / min"],
33 + ["Save a key", "12 / min"],
34 + ["Validate a key", "10 / min"],
35 + ["Model sync", "6 / min"],
36 + ["File uploads", "40 / min"],
37 + ["Search", "120 / min"],
38 + ["Share / conversation actions", "20 / min"],
39 +];
40 +
41 +const HEADERS: [string, string][] = [
42 + ["Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; connect-src 'self'; img-src 'self' data: blob: https:; font-src 'self' data:; worker-src 'self' blob:; upgrade-insecure-requests"],
43 + ["Strict-Transport-Security", "max-age=31536000; includeSubDomains (production)"],
44 + ["X-Frame-Options", "DENY"],
45 + ["X-Content-Type-Options", "nosniff"],
46 + ["Referrer-Policy", "strict-origin-when-cross-origin"],
47 + ["Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"],
48 +];
49 +
50 +const AUDIT_EVENTS = ["account.created", "login", "email.verified", "email.change_requested", "password.reset_requested", "password.reset", "provider.key_added", "provider.key_replaced", "provider.key_deleted", "account.deleted", "export · share · branch · duplicate"];
51 +
52 +export default function SecurityPage() {
53 + return (
54 + <>
55 + {/* Header */}
56 + <section className="relative overflow-hidden">
57 + <div aria-hidden className="dot-grid pointer-events-none absolute inset-0 opacity-60 [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,black_30%,transparent_100%)] dark:opacity-40" />
58 + <Container className="relative pb-10 pt-12 sm:pb-14 sm:pt-20">
59 + <Reveal className="max-w-3xl">
60 + <Eyebrow>Security</Eyebrow>
61 + <h1 className="mt-3 text-balance text-4xl font-semibold tracking-[-0.03em]">Where your keys go, how they are encrypted, and what we log.</h1>
62 + <p className="mt-5 max-w-2xl text-pretty text-base leading-7 text-fg-muted sm:text-lg sm:leading-8">
63 + PolyLLM handles the most sensitive thing a developer owns: API keys with a billing account attached. This page describes the actual implementation — every statement below is checked against the source code, not a policy document. When something is a trade-off, we say so.
64 + </p>
65 + </Reveal>
66 + </Container>
67 + </section>
68 +
69 + {/* Flow */}
70 + <Section tone="subtle" className="py-12 sm:py-16">
71 + <SectionHeading eyebrow="Request path" title="Browser → PolyLLM encrypted server layer → Provider" description="Your browser never holds a provider key after the moment you paste it. The server encrypts it, stores the envelope, and only decrypts it in memory when it is about to talk to the provider you chose." />
72 + <div className="mt-10">
73 + <SecurityFlow />
74 + </div>
75 + </Section>
76 +
77 + {/* Key lifecycle */}
78 + <Section id="keys" className="py-14 sm:py-20">
79 + <div className="grid gap-10 lg:grid-cols-12">
80 + <div className="lg:col-span-4">
81 + <SectionHeading eyebrow="API key lifecycle" title="From paste to provider, step by step" description="The implementation lives in a single module (src/lib/crypto/keys.ts) and one service (src/lib/providers/keys.ts). Here is what they do." />
82 + </div>
83 + <div className="lg:col-span-8">
84 + <KeyLifecycle />
85 + </div>
86 + </div>
87 + </Section>
88 +
89 + {/* Account & sessions */}
90 + <Section id="account" tone="subtle" className="py-14 sm:py-20">
91 + <SectionHeading eyebrow="Account & sessions" title="Passwords, cookies and e-mail verification" />
92 + <div className="mt-10 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
93 + <Reveal className="flex gap-3.5">
94 + <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent [&_svg]:size-4.5">
95 + <Fingerprint />
96 + </span>
97 + <div>
98 + <h3 className="text-[15px] font-semibold tracking-tight">Argon2id password hashing</h3>
99 + <p className="mt-1 text-[13.5px] leading-6 text-fg-muted">
100 + Passwords are hashed with Argon2id at the OWASP-recommended parameters: 19 MiB memory, 2 iterations, parallelism 1 (the <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">argon2</code> native library, configured in Better Auth). Minimum 10 and maximum 128 characters. The password itself is never stored.
101 + </p>
102 + </div>
103 + </Reveal>
104 + <Reveal delay={0.05} className="flex gap-3.5">
105 + <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent [&_svg]:size-4.5">
106 + <Cookie />
107 + </span>
108 + <div>
109 + <h3 className="text-[15px] font-semibold tracking-tight">HttpOnly, SameSite=Lax, Secure cookies</h3>
110 + <p className="mt-1 text-[13.5px] leading-6 text-fg-muted">
111 + Sessions live in Better Auth cookies prefixed <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">polyllm</code>: HttpOnly (not readable by JavaScript), SameSite=Lax, and Secure in production. Sessions last 30 days and are refreshed daily; a 5-minute cookie cache avoids a database round-trip on every request. Nothing sensitive is kept in localStorage.
112 + </p>
113 + </div>
114 + </Reveal>
115 + <Reveal delay={0.1} className="flex gap-3.5">
116 + <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent [&_svg]:size-4.5">
117 + <KeyRound />
118 + </span>
119 + <div>
120 + <h3 className="text-[15px] font-semibold tracking-tight">Verified e-mail, revocable sessions</h3>
121 + <p className="mt-1 text-[13.5px] leading-6 text-fg-muted">
122 + E-mail verification is mandatory before the first sign-in (links valid 24 h). Password reset links expire after 60 minutes and every other session is revoked when the password changes. Changing your e-mail or deleting the account requires a confirmation link sent to the current address. Auth requests are only accepted from the app&apos;s own origin.
123 + </p>
124 + </div>
125 + </Reveal>
126 + </div>
127 + </Section>
128 +
129 + {/* Headers */}
130 + <Section id="headers" className="py-14 sm:py-20">
131 + <div className="grid gap-10 lg:grid-cols-12">
132 + <div className="lg:col-span-4">
133 + <SectionHeading
134 + eyebrow="Transport & headers"
135 + title="A strict Content-Security-Policy and hardened response headers"
136 + description={
137 + <>
138 + Set globally in <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">next.config.ts</code>. The site cannot be framed, scripts and connections are restricted to our own origin, and HTTPS is enforced for a year with HSTS. The <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">X-Powered-By</code> header is removed.
139 + </>
140 + }
141 + />
142 + <Reveal delay={0.1}>
143 + <p className="mt-6 rounded-xl border border-dashed border-border-strong/70 p-4 text-[13px] leading-6 text-fg-muted">
144 + <span className="font-medium text-fg">Trade-off, stated plainly:</span> <code className="font-mono text-[12px]">script-src</code> and <code className="font-mono text-[12px]">style-src</code> include <code className="font-mono text-[12px]">&apos;unsafe-inline&apos;</code> because Next.js hydration and Radix components inject inline scripts and style attributes. There are no third-party scripts on the site, so the practical exposure is small, but nonces would be stricter and are on the list.
145 + </p>
146 + </Reveal>
147 + </div>
148 + <Reveal delay={0.05} className="lg:col-span-8">
149 + <dl className="divide-y divide-hairline rounded-2xl bg-bg-subtle">
150 + {HEADERS.map(([k, v]) => (
151 + <div key={k} className="grid gap-1 px-4 py-3.5 sm:grid-cols-[14rem_1fr] sm:gap-4 sm:px-5">
152 + <dt className="font-mono text-[12.5px] font-medium text-fg">{k}</dt>
153 + <dd className="break-words font-mono text-[12px] leading-5 text-fg-muted">{v}</dd>
154 + </div>
155 + ))}
156 + </dl>
157 + </Reveal>
158 + </div>
159 + </Section>
160 +
161 + {/* Rate limits */}
162 + <Section id="rate-limits" tone="subtle" className="py-14 sm:py-20">
163 + <SectionHeading
164 + eyebrow="Rate limits"
165 + title="Brute force and abuse are blunted per account"
166 + description="Authentication routes are limited by Better Auth per client; application routes use a sliding-window limiter keyed by user id. Limits are conservative for a single-user workspace and return HTTP 429 with a Retry-After header."
167 + />
168 + <div className="mt-10 grid gap-6 md:grid-cols-2">
169 + {[
170 + { title: "Authentication", rows: AUTH_LIMITS, icon: <ShieldCheck /> },
171 + { title: "Application", rows: APP_LIMITS, icon: <Gauge /> },
172 + ].map((group, gi) => (
173 + <Reveal key={group.title} delay={0.05 * gi}>
174 + <h3 className="flex items-center gap-2 text-[15px] font-semibold tracking-tight [&_svg]:size-4 [&_svg]:text-accent">
175 + {group.icon} {group.title}
176 + </h3>
177 + <dl className="mt-3 divide-y divide-hairline rounded-2xl bg-bg-elevated">
178 + {group.rows.map(([k, v]) => (
179 + <div key={k} className="flex items-center justify-between gap-4 px-4 py-2.5 text-[13.5px]">
180 + <dt className="text-fg-muted">{k}</dt>
181 + <dd className="font-mono text-[12.5px] tabular-nums text-fg">{v}</dd>
182 + </div>
183 + ))}
184 + </dl>
185 + </Reveal>
186 + ))}
187 + </div>
188 + </Section>
189 +
190 + {/* Audit log */}
191 + <Section id="audit" className="py-14 sm:py-20">
192 + <div className="grid gap-10 lg:grid-cols-12">
193 + <div className="lg:col-span-5">
194 + <SectionHeading
195 + eyebrow="Audit log"
196 + title="Security-relevant actions are recorded and shown to you"
197 + description="Each event stores the action, your user id, the requesting IP address, a truncated user agent and non-sensitive metadata (for example which provider a key belongs to — never the key). The log is yours: the most recent events are displayed in Settings → Account, and deleting your account removes them with everything else."
198 + />
199 + <Reveal delay={0.1}>
200 + <p className="mt-5 flex items-start gap-2 text-[13.5px] leading-6 text-fg-muted">
201 + <ScrollText className="mt-1 size-4 shrink-0 text-accent" />
202 + Server logs are structured JSON; every string passes through a secret redactor (provider key patterns, bearer tokens) and keys named like api key, password, token, cookie, prompt or content are replaced by <code className="rounded bg-bg-muted px-1 font-mono text-[12px] text-fg">[redacted]</code>. Prompts and answers are not logged.
203 + </p>
204 + </Reveal>
205 + </div>
206 + <Reveal delay={0.05} className="lg:col-span-7">
207 + <ul className="flex flex-wrap gap-2">
208 + {AUDIT_EVENTS.map((e) => (
209 + <li key={e} className="rounded-lg bg-bg-subtle px-2.5 py-1.5 font-mono text-[12px] text-fg-muted">
210 + {e}
211 + </li>
212 + ))}
213 + </ul>
214 + <div className="mt-6 rounded-2xl bg-bg-subtle p-4 sm:p-5">
215 + <p className="text-[13px] font-medium">What we deliberately do not do</p>
216 + <ul className="mt-2 space-y-1.5 text-[13.5px] leading-6 text-fg-muted">
217 + <li>· No analytics trackers, advertising pixels or fingerprinting scripts on any page.</li>
218 + <li>· No third-party inference brokers: requests go to the provider you picked and nowhere else.</li>
219 + <li>· No training on your data, and temporary chats are never written to the database.</li>
220 + <li>· No owner keys for user traffic: the operator&apos;s own provider keys are used only to sync the public model registry.</li>
221 + </ul>
222 + </div>
223 + </Reveal>
224 + </div>
225 + </Section>
226 +
227 + {/* CTA */}
228 + <Section tone="subtle" className="py-14 sm:py-16">
229 + <Reveal className="flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-center">
230 + <div>
231 + <h2 className="text-balance text-2xl font-semibold tracking-tight">Questions about any of this?</h2>
232 + <p className="mt-2 max-w-xl text-[15px] leading-7 text-fg-muted">
233 + Write to{" "}
234 + <a href="mailto:contact@spboucher.ai" className="font-medium text-accent underline-offset-4 hover:underline">
235 + contact@spboucher.ai
236 + </a>
237 + . Responsible disclosure is welcome; please give us a reasonable window before publishing.
238 + </p>
239 + </div>
240 + <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
241 + <Button asChild size="lg" className="w-full sm:w-auto">
242 + <Link href="/signup">
243 + Start using PolyLLM <ArrowRight />
244 + </Link>
245 + </Button>
246 + <Button asChild size="lg" variant="outline" className="w-full sm:w-auto">
247 + <Link href="/privacy">Privacy policy</Link>
248 + </Button>
249 + </div>
250 + </Reveal>
251 + </Section>
252 + </>
253 + );
254 +}
added src/app/api/arena/[id]/export/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +import { withUser, ApiError } from "@/lib/api";
2 +import { exportArenaSession } from "@/lib/arena/service";
3 +import { LIMITS } from "@/lib/rate-limit";
4 +
5 +export const dynamic = "force-dynamic";
6 +type P = { id: string };
7 +
8 +/** POST /api/arena/:id/export?format=markdown|json → file download (models, parameters, metrics, votes, responses). */
9 +export const POST = withUser<P>(
10 + async ({ req, user }, { id }) => {
11 + const format = new URL(req.url).searchParams.get("format") ?? "markdown";
12 + if (format !== "markdown" && format !== "json" && format !== "md") throw new ApiError(400, "format must be markdown or json", "VALIDATION_ERROR");
13 + const out = await exportArenaSession(user.id, id, format === "json" ? "json" : "markdown");
14 + return new Response(out.body, { headers: { "Content-Type": out.contentType, "Content-Disposition": `attachment; filename="${out.filename}"`, "X-Filename": out.filename } });
15 + },
16 + { limit: { ...LIMITS.share, key: "arena-export" } },
17 +);
added src/app/api/arena/[id]/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { withUser, json } from "@/lib/api";
2 +import { deleteArenaSession, getArenaSession } from "@/lib/arena/service";
3 +
4 +export const dynamic = "force-dynamic";
5 +type P = { id: string };
6 +
7 +/** GET /api/arena/:id → { session } (responses + votes, owner only). */
8 +export const GET = withUser<P>(async ({ user }, { id }) => json({ session: await getArenaSession(user.id, id) }));
9 +
10 +/** DELETE /api/arena/:id → { ok } — removes the session, its responses, votes and share links. */
11 +export const DELETE = withUser<P>(async ({ user }, { id }) => {
12 + await deleteArenaSession(user.id, id);
13 + return json({ ok: true });
14 +});
added src/app/api/arena/[id]/share/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { withUser, json } from "@/lib/api";
2 +import { getArenaShare, revokeArenaShare, shareArenaSession } from "@/lib/arena/service";
3 +import { LIMITS } from "@/lib/rate-limit";
4 +
5 +export const dynamic = "force-dynamic";
6 +type P = { id: string };
7 +
8 +/** GET /api/arena/:id/share → { share: { id, createdAt, viewCount } | null } */
9 +export const GET = withUser<P>(async ({ user }, { id }) => json({ share: await getArenaShare(user.id, id) }));
10 +
11 +/** POST /api/arena/:id/share → { share } — creates (or refreshes) the public snapshot at /share/arena/:shareId. */
12 +export const POST = withUser<P>(async ({ user }, { id }) => json({ share: await shareArenaSession(user.id, id) }, { status: 201 }), { limit: { ...LIMITS.share, key: "arena-share" } });
13 +
14 +/** DELETE /api/arena/:id/share → { ok } — revokes every active link for the session. */
15 +export const DELETE = withUser<P>(async ({ user }, { id }) => {
16 + await revokeArenaShare(user.id, id);
17 + return json({ ok: true });
18 +});
added src/app/api/arena/scoreboard/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { withUser, json, ApiError } from "@/lib/api";
2 +import { getArenaScoreboard } from "@/lib/arena/service";
3 +import { CRITERION_RE, isTaskCategory } from "@/lib/arena/scoring";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +/**
8 + * GET /api/arena/scoreboard?category=coding|research|writing|reasoning|general&criterion=best|value|…
9 + * → { rows: ScoreboardRow[], sessions, votes }
10 + */
11 +export const GET = withUser(async ({ req, user }) => {
12 + const url = new URL(req.url);
13 + const category = url.searchParams.get("category") || null;
14 + const criterion = url.searchParams.get("criterion") || null;
15 + if (category && !isTaskCategory(category)) throw new ApiError(400, "Unknown category", "VALIDATION_ERROR");
16 + if (criterion && !CRITERION_RE.test(criterion)) throw new ApiError(400, "Unknown criterion", "VALIDATION_ERROR");
17 + return json(await getArenaScoreboard(user.id, { category: category && isTaskCategory(category) ? category : null, criterion }));
18 +});
added src/app/api/arena/vote/route.ts +27 −0
@@ -0,0 +1,27 @@
1 +import { withUser, parseBody, json } from "@/lib/api";
2 +import { arenaRetractVoteSchema, arenaVoteSchema } from "@/lib/chat/schemas";
3 +import { castArenaVote, retractArenaVote } from "@/lib/arena/service";
4 +import { LIMITS } from "@/lib/rate-limit";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +/**
9 + * POST /api/arena/vote { sessionId, responseId, criterion, category? }
10 + * → { votes, ratings } — upserts the vote for (session, criterion); legacy ratings mirrored.
11 + */
12 +export const POST = withUser(
13 + async ({ req, user }) => {
14 + const body = await parseBody(req, arenaVoteSchema);
15 + return json(await castArenaVote(user.id, body));
16 + },
17 + { limit: { max: 120, windowMs: 60_000, key: "arena-vote" } },
18 +);
19 +
20 +/** DELETE /api/arena/vote { sessionId, criterion } → { votes, ratings } — retracts the vote. */
21 +export const DELETE = withUser(
22 + async ({ req, user }) => {
23 + const body = await parseBody(req, arenaRetractVoteSchema);
24 + return json(await retractArenaVote(user.id, body));
25 + },
26 + { limit: { max: LIMITS.arena.max * 6, windowMs: LIMITS.arena.windowMs, key: "arena-vote" } },
27 +);
added src/app/api/chat/adopt/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { withUser, parseBody, json } from "@/lib/api";
2 +import { LIMITS } from "@/lib/rate-limit";
3 +import { chatAdoptSchema } from "@/lib/chat/schemas";
4 +import { adoptMessage, toPublicMessage } from "@/lib/chat/service";
5 +import { toPublicConversation } from "@/lib/conversations/service";
6 +
7 +export const dynamic = "force-dynamic";
8 +
9 +/**
10 + * POST /api/chat/adopt — "Continue with this model" from an inline Compare / Arena run.
11 + * Body: { conversationId?, modelKey, arenaResponseId? | content, userText?, systemPrompt?, settings?, projectId? }
12 + * → 201 { conversation, userMessage | null, message, isNewConversation }
13 + */
14 +export const POST = withUser(
15 + async (ctx) => {
16 + const input = await parseBody(ctx.req, chatAdoptSchema, 1_000_000);
17 + const res = await adoptMessage({ userId: ctx.user.id, requestId: ctx.requestId, ip: ctx.ip }, input);
18 + return json({ conversation: toPublicConversation(res.conversation), userMessage: res.userMessage ? toPublicMessage(res.userMessage) : null, message: toPublicMessage(res.message), isNewConversation: res.isNewConversation }, { status: 201 });
19 + },
20 + { limit: { ...LIMITS.chat, key: "chat-adopt" } },
21 +);
modified src/app/api/chat/route.ts +5 −0
@@ -10,6 +10,9 @@ export const maxDuration = 900;
10 10 /**
11 11 * POST /api/chat — streams a model turn as Server-Sent Events.
12 12 * Events: meta → (text-delta | reasoning-delta | tool-* | citation | server-tool)* → done | error
13 + *
14 + * `meta` carries `ephemeral` (temporary chat: conversationId is the "ephemeral" sentinel and nothing
15 + * is persisted except the usage record) and `requestId` (shown in the error details sheet).
13 16 */
14 17 export const POST = withUser(
15 18 async (ctx) => {
@@ -30,6 +33,8 @@ export const POST = withUser(
30 33 assistantMessageId: turn.assistantMessage.id,
31 34 modelKey: turn.model.key,
32 35 clientId: input.clientId,
36 + ephemeral: turn.ephemeral,
37 + requestId: ctx.requestId,
33 38 });
34 39 const outcome = await runTurn({ userId: ctx.user.id, requestId: ctx.requestId, ip: ctx.ip }, turn, emit, controller.signal);
35 40 if (outcome.error && !outcome.message.content) emit({ type: "error", error: outcome.error });
modified src/app/api/conversations/[id]/actions/route.ts +19 −9
@@ -1,7 +1,8 @@
1 1 import { z } from "zod";
2 2 import { withUser, parseBody, json, ApiError } from "@/lib/api";
3 −import { duplicateConversation, exportConversation, shareConversation, revokeShare, getShare, deleteMessage } from "@/lib/conversations/service";
3 +import { duplicateConversation, exportConversation, shareConversation, revokeShare, revokeShareById, getShare, listShares, deleteMessage, EXPORT_FORMATS } from "@/lib/conversations/service";
4 4 import { LIMITS } from "@/lib/rate-limit";
5 +import { APP_URL } from "@/lib/env";
5 6
6 7 export const dynamic = "force-dynamic";
7 8 type P = { id: string };
@@ -9,10 +10,14 @@ type P = { id: string };
9 10 const schema = z.discriminatedUnion("action", [
10 11 z.object({ action: z.literal("duplicate"), title: z.string().max(200).optional() }),
11 12 z.object({ action: z.literal("branch"), messageId: z.string().max(64), title: z.string().max(200).optional() }),
12 − z.object({ action: z.literal("export"), format: z.enum(["json", "markdown"]) }),
13 − z.object({ action: z.literal("share") }),
14 − z.object({ action: z.literal("unshare") }),
13 + /** Prefer `GET /api/conversations/[id]/export?format=` for downloads; kept for existing callers. */
14 + z.object({ action: z.literal("export"), format: z.enum(EXPORT_FORMATS) }),
15 + /** `messageIds` → share only those messages (new link). Without it → the conversation's single full link (upsert). */
16 + z.object({ action: z.literal("share"), messageIds: z.array(z.string().max(64)).max(500).optional() }),
17 + /** Revoke every active link of the conversation, or one link when `shareId` is given. */
18 + z.object({ action: z.literal("unshare"), shareId: z.string().max(64).optional() }),
15 19 z.object({ action: z.literal("share-status") }),
20 + z.object({ action: z.literal("list-shares") }),
16 21 z.object({ action: z.literal("delete-message"), messageId: z.string().max(64) }),
17 22 ]);
18 23
@@ -25,16 +30,21 @@ export const POST = withUser<P>(
25 30 case "branch":
26 31 return json({ conversation: await duplicateConversation(user.id, id, { uptoMessageId: body.messageId, title: body.title }) }, { status: 201 });
27 32 case "export": {
28 − const out = await exportConversation(user.id, id, body.format);
33 + const out = await exportConversation(user.id, id, body.format, { appUrl: APP_URL });
29 34 return new Response(out.body, { headers: { "Content-Type": out.contentType, "Content-Disposition": `attachment; filename="${out.filename}"` } });
30 35 }
31 − case "share":
32 − return json(await shareConversation(user.id, id));
36 + case "share": {
37 + const res = await shareConversation(user.id, id, { messageIds: body.messageIds });
38 + return json({ ...res, path: `/share/${res.id}` }, { status: res.created ? 201 : 200 });
39 + }
33 40 case "unshare":
34 − await revokeShare(user.id, id);
41 + if (body.shareId) await revokeShareById(user.id, body.shareId);
42 + else await revokeShare(user.id, id);
35 43 return json({ ok: true });
36 44 case "share-status":
37 − return json({ share: await getShare(user.id, id) });
45 + return json({ share: await getShare(user.id, id), shares: await listShares(user.id, id) });
46 + case "list-shares":
47 + return json({ shares: await listShares(user.id, id) });
38 48 case "delete-message":
39 49 await deleteMessage(user.id, id, body.messageId);
40 50 return json({ ok: true });
added src/app/api/conversations/[id]/export/route.ts +33 −0
@@ -0,0 +1,33 @@
1 +import { withUser, ApiError } from "@/lib/api";
2 +import { exportConversation, EXPORT_FORMATS, type ExportFormat } from "@/lib/conversations/service";
3 +import { APP_URL } from "@/lib/env";
4 +
5 +export const dynamic = "force-dynamic";
6 +type P = { id: string };
7 +
8 +/**
9 + * GET /api/conversations/[id]/export?format=json|markdown|txt|html[&download=1][&print=1]
10 + *
11 + * - `download=1` → `Content-Disposition: attachment` (default for json/markdown/txt)
12 + * - html without `download` renders inline (opened in a new tab by the client for "PDF (print)");
13 + * `print=1` embeds a tiny script that opens the print dialog on load.
14 + */
15 +export const GET = withUser<P>(
16 + async ({ req, user }, { id }) => {
17 + const sp = new URL(req.url).searchParams;
18 + const format = (sp.get("format") ?? "markdown") as ExportFormat;
19 + if (!(EXPORT_FORMATS as readonly string[]).includes(format)) throw new ApiError(400, `Unknown format. Use one of: ${EXPORT_FORMATS.join(", ")}`, "BAD_FORMAT");
20 + const print = sp.get("print") === "1";
21 + const inline = format === "html" && sp.get("download") !== "1";
22 + const out = await exportConversation(user.id, id, format, { print, appUrl: APP_URL });
23 + return new Response(out.body, {
24 + headers: {
25 + "Content-Type": out.contentType,
26 + "Cache-Control": "private, no-store",
27 + "X-Robots-Tag": "noindex",
28 + ...(inline ? {} : { "Content-Disposition": `attachment; filename="${out.filename}"` }),
29 + },
30 + });
31 + },
32 + { limit: { max: 40, windowMs: 60_000, key: "conv-export" } },
33 +);
modified src/app/api/conversations/[id]/route.ts +1 −0
@@ -13,6 +13,7 @@ const patchSchema = z.object({
13 13 pinned: z.boolean().optional(),
14 14 archived: z.boolean().optional(),
15 15 folderId: z.string().max(64).nullable().optional(),
16 + projectId: z.string().max(64).nullable().optional(),
16 17 systemPrompt: z.string().max(50_000).nullable().optional(),
17 18 settings: z.record(z.string(), z.unknown()).optional(),
18 19 modelKey: z.string().max(120).nullable().optional(),
modified src/app/api/conversations/route.ts +2 −0
@@ -14,6 +14,7 @@ export const GET = withUser(async ({ req, user }) => {
14 14 provider: provider && isProviderId(provider) ? provider : undefined,
15 15 modelKey: p.get("model") ?? undefined,
16 16 folderId: p.get("folder") ?? undefined,
17 + projectId: p.get("projectId") ?? undefined,
17 18 archived: p.get("archived") === "1",
18 19 pinned: p.get("pinned") === "1" ? true : undefined,
19 20 since: p.get("since") ? new Date(p.get("since")!) : undefined,
@@ -29,6 +30,7 @@ const createSchema = z.object({
29 30 modelKey: z.string().max(120).nullable().optional(),
30 31 systemPrompt: z.string().max(50_000).nullable().optional(),
31 32 folderId: z.string().max(64).nullable().optional(),
33 + projectId: z.string().max(64).nullable().optional(),
32 34 settings: z.record(z.string(), z.unknown()).optional(),
33 35 });
34 36
added src/app/api/endpoints/[id]/route.ts +29 −0
@@ -0,0 +1,29 @@
1 +import { withUser, parseBody, json, ApiError } from "@/lib/api";
2 +import { getEndpoint, updateEndpoint, deleteEndpoint, ENDPOINT_PATCH_SCHEMA } from "@/lib/endpoints/service";
3 +import { LIMITS } from "@/lib/rate-limit";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +type P = { id: string };
8 +
9 +/** GET /api/endpoints/[id] → `{ endpoint: PublicEndpoint }` */
10 +export const GET = withUser<P>(async ({ user }, { id }) => {
11 + const endpoint = await getEndpoint(user.id, id);
12 + if (!endpoint) throw new ApiError(404, "Endpoint not found", "NOT_FOUND");
13 + return json({ endpoint });
14 +});
15 +
16 +/** PATCH /api/endpoints/[id] — partial `EndpointInput` (apiKey `null` clears it) → `{ endpoint }` */
17 +export const PATCH = withUser<P>(
18 + async ({ req, user, ip }, { id }) => {
19 + const body = await parseBody(req, ENDPOINT_PATCH_SCHEMA, 64_000);
20 + return json({ endpoint: await updateEndpoint(user.id, id, body, ip) });
21 + },
22 + { limit: { ...LIMITS.keySave, key: "endpoint-save" } },
23 +);
24 +
25 +/** DELETE /api/endpoints/[id] → `{ ok: true }` */
26 +export const DELETE = withUser<P>(async ({ user, ip }, { id }) => {
27 + await deleteEndpoint(user.id, id, ip);
28 + return json({ ok: true });
29 +});
added src/app/api/endpoints/[id]/sync/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +import { withUser, json } from "@/lib/api";
2 +import { probeEndpoint, getEndpoint } from "@/lib/endpoints/service";
3 +import { LIMITS } from "@/lib/rate-limit";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +type P = { id: string };
8 +
9 +/** POST /api/endpoints/[id]/sync — discover models (`GET {baseUrl}{modelsPath}`) → `{ ok, models: PolyModel[], latencyMs, error?, endpoint }` */
10 +export const POST = withUser<P>(
11 + async ({ user }, { id }) => {
12 + const r = await probeEndpoint(user.id, id);
13 + return json({ ok: r.ok, models: r.models, latencyMs: r.latencyMs, error: r.error, errorCode: r.errorCode, endpoint: await getEndpoint(user.id, id) });
14 + },
15 + { limit: { ...LIMITS.modelSync, key: "endpoint-sync" } },
16 +);
added src/app/api/endpoints/[id]/test/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +import { withUser, json } from "@/lib/api";
2 +import { probeEndpoint, getEndpoint } from "@/lib/endpoints/service";
3 +import { LIMITS } from "@/lib/rate-limit";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +type P = { id: string };
8 +
9 +/** POST /api/endpoints/[id]/test → `{ ok, latencyMs, modelsAvailable, error?, errorCode?, endpoint }` */
10 +export const POST = withUser<P>(
11 + async ({ user }, { id }) => {
12 + const r = await probeEndpoint(user.id, id);
13 + return json({ ok: r.ok, latencyMs: r.latencyMs, modelsAvailable: r.modelsAvailable, error: r.error, errorCode: r.errorCode, endpoint: await getEndpoint(user.id, id) });
14 + },
15 + { limit: { ...LIMITS.keyValidate, key: "endpoint-test" } },
16 +);
added src/app/api/endpoints/route.ts +31 −0
@@ -0,0 +1,31 @@
1 +import { z } from "zod";
2 +import { withUser, parseBody, json } from "@/lib/api";
3 +import { listEndpoints, createEndpoint, probeEndpoint, ENDPOINT_INPUT_SCHEMA } from "@/lib/endpoints/service";
4 +import { privateEndpointsAllowed } from "@/lib/endpoints/ssrf";
5 +import { LIMITS } from "@/lib/rate-limit";
6 +
7 +export const dynamic = "force-dynamic";
8 +
9 +/** GET /api/endpoints → `{ endpoints: PublicEndpoint[], allowPrivate: boolean }` */
10 +export const GET = withUser(async ({ user }) => json({ endpoints: await listEndpoints(user.id), allowPrivate: privateEndpointsAllowed() }));
11 +
12 +const createSchema = ENDPOINT_INPUT_SCHEMA.extend({ validate: z.boolean().optional() });
13 +
14 +/**
15 + * POST /api/endpoints — body `EndpointInput & { validate?: boolean }` (default true).
16 + * → 201 `{ endpoint: PublicEndpoint, test?: { ok, latencyMs, modelsAvailable, error? } }`
17 + */
18 +export const POST = withUser(
19 + async ({ req, user, ip }) => {
20 + const { validate, ...input } = await parseBody(req, createSchema, 64_000);
21 + let endpoint = await createEndpoint(user.id, input, ip);
22 + let test: { ok: boolean; latencyMs: number; modelsAvailable: number; error?: string } | undefined;
23 + if (validate !== false) {
24 + const r = await probeEndpoint(user.id, endpoint.id);
25 + test = { ok: r.ok, latencyMs: r.latencyMs, modelsAvailable: r.modelsAvailable, error: r.error };
26 + endpoint = (await listEndpoints(user.id)).find((e) => e.id === endpoint.id) ?? endpoint;
27 + }
28 + return json({ endpoint, test }, { status: 201 });
29 + },
30 + { limit: { ...LIMITS.keySave, key: "endpoint-save" } },
31 +);
added src/app/api/library/files/[id]/route.ts +45 −0
@@ -0,0 +1,45 @@
1 +import { z } from "zod";
2 +import { withUser, json, parseBody } from "@/lib/api";
3 +import { getFile, updateFile, deleteFile, toPublicFile } from "@/lib/library/service";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +type P = { id: string };
8 +
9 +/**
10 + * GET /api/library/files/:id — streams the payload to its owner (image previews, downloads).
11 + * `?meta=1` returns the metadata JSON instead; `?download=1` forces an attachment disposition.
12 + */
13 +export const GET = withUser<P>(async ({ req, user }, { id }) => {
14 + const p = new URL(req.url).searchParams;
15 + const row = await getFile(user.id, id);
16 + if (p.get("meta") === "1") return json({ file: toPublicFile(row) });
17 + const buf = Buffer.from(row.dataBase64, "base64");
18 + const disposition = p.get("download") === "1" ? "attachment" : "inline";
19 + return new Response(buf, {
20 + headers: {
21 + "Content-Type": row.mimeType,
22 + "Content-Length": String(buf.length),
23 + "Cache-Control": "private, max-age=3600",
24 + "Content-Disposition": `${disposition}; filename="${encodeURIComponent(row.name)}"`,
25 + "X-Content-Type-Options": "nosniff",
26 + },
27 + });
28 +});
29 +
30 +const patchSchema = z.object({
31 + name: z.string().min(1).max(200).optional(),
32 + description: z.string().max(500).nullable().optional(),
33 + /** Move to a project, or `null` for the global library. */
34 + projectId: z.string().max(64).nullable().optional(),
35 +});
36 +
37 +export const PATCH = withUser<P>(async ({ req, user }, { id }) => {
38 + const body = await parseBody(req, patchSchema);
39 + return json({ file: await updateFile(user.id, id, body) });
40 +});
41 +
42 +export const DELETE = withUser<P>(async ({ user }, { id }) => {
43 + await deleteFile(user.id, id);
44 + return json({ ok: true });
45 +});
added src/app/api/library/files/attach/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +import { z } from "zod";
2 +import { withUser, parseBody, json } from "@/lib/api";
3 +import { attachFiles } from "@/lib/library/service";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +/**
8 + * POST /api/library/files/attach { fileIds: string[] } → { attachments: [{ id, kind, name, mimeType, sizeBytes, width, height }] }
9 + * Each library file is copied into `message_attachments` as a pending attachment (no message/conversation yet),
10 + * i.e. the same shape POST /api/attachments returns, so the composer can send the ids in `message.attachmentIds`.
11 + */
12 +export const POST = withUser(async ({ req, user }) => {
13 + const body = await parseBody(req, z.object({ fileIds: z.array(z.string().max(64)).min(1).max(10) }));
14 + return json({ attachments: await attachFiles(user.id, body.fileIds) }, { status: 201 });
15 +});
added src/app/api/library/files/route.ts +45 −0
@@ -0,0 +1,45 @@
1 +import { withUser, json, ApiError } from "@/lib/api";
2 +import { LIMITS } from "@/lib/rate-limit";
3 +import { listFiles, saveFile, deleteFile, LIBRARY_MAX_BYTES } from "@/lib/library/service";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +/** GET /api/library/files?projectId=<id|none>&q=&kind= → { files } (metadata only). */
8 +export const GET = withUser(async ({ req, user }) => {
9 + const p = new URL(req.url).searchParams;
10 + const files = await listFiles(user.id, {
11 + projectId: p.get("projectId") ?? undefined,
12 + q: p.get("q") ?? undefined,
13 + kind: p.get("kind") ?? undefined,
14 + limit: p.get("limit") ? Number(p.get("limit")) : undefined,
15 + });
16 + return json({ files });
17 +});
18 +
19 +/** POST /api/library/files — multipart: file, projectId?, description? → 201 { file } */
20 +export const POST = withUser(
21 + async ({ req, user }) => {
22 + const len = Number(req.headers.get("content-length") ?? 0);
23 + if (len > LIBRARY_MAX_BYTES + 8192) throw new ApiError(413, "File too large (max 10 MB)", "PAYLOAD_TOO_LARGE");
24 + const form = await req.formData().catch(() => null);
25 + const file = form?.get("file");
26 + if (!(file instanceof File)) throw new ApiError(400, "Missing file", "BAD_REQUEST");
27 + const projectId = form?.get("projectId");
28 + const description = form?.get("description");
29 + const saved = await saveFile(user.id, {
30 + file,
31 + projectId: typeof projectId === "string" && projectId && projectId !== "none" ? projectId.slice(0, 64) : null,
32 + description: typeof description === "string" ? description : null,
33 + });
34 + return json({ file: saved }, { status: 201 });
35 + },
36 + { limit: { ...LIMITS.upload, key: "library-upload" } },
37 +);
38 +
39 +/** DELETE /api/library/files?id= */
40 +export const DELETE = withUser(async ({ req, user }) => {
41 + const id = new URL(req.url).searchParams.get("id");
42 + if (!id) throw new ApiError(400, "Missing id");
43 + await deleteFile(user.id, id);
44 + return json({ ok: true });
45 +});
modified src/app/api/models/route.ts +6 −2
@@ -3,14 +3,18 @@ import { withUser, parseBody, json } from "@/lib/api";
3 3 import { listRegistryModels, favoriteModels, recentModels, toggleFavorite } from "@/lib/ai/registry";
4 4 import { modelLabels, setModelLabel } from "@/lib/ai/registry/user-models";
5 5 import { listConnections } from "@/lib/providers/keys";
6 +import { listCustomModels, countValidEndpoints } from "@/lib/endpoints/service";
6 7
7 8 export const dynamic = "force-dynamic";
8 9
9 10 /** GET /api/models — the normalized registry plus the caller's favorites/recents/labels and connected providers. */
10 11 export const GET = withUser(async ({ req, user }) => {
11 12 const includeDeprecated = new URL(req.url).searchParams.get("deprecated") === "1";
12 − const [models, favorites, recents, labels, connections] = await Promise.all([listRegistryModels({ includeDeprecated }), favoriteModels(user.id), recentModels(user.id), modelLabels(user.id), listConnections(user.id)]);
13 − return json({ models, favorites, recents, labels, connectedProviders: connections.filter((c) => c.status !== "invalid").map((c) => c.provider) });
13 + const [models, favorites, recents, labels, connections, customModels, validEndpoints] = await Promise.all([listRegistryModels({ includeDeprecated }), favoriteModels(user.id), recentModels(user.id), modelLabels(user.id), listConnections(user.id), listCustomModels(user.id), countValidEndpoints(user.id)]);
14 + const connectedProviders = connections.filter((c) => c.status !== "invalid").map((c) => c.provider);
15 + // Custom OpenAI-compatible endpoints (Settings → Endpoints) appear as provider "custom", keyed `custom/<endpointId>:<modelId>`.
16 + if (validEndpoints > 0) connectedProviders.push("custom");
17 + return json({ models: [...models, ...customModels], favorites, recents, labels, connectedProviders });
14 18 });
15 19
16 20 const bodySchema = z.discriminatedUnion("action", [
added src/app/api/projects/[id]/route.ts +32 −0
@@ -0,0 +1,32 @@
1 +import { withUser, parseBody, json } from "@/lib/api";
2 +import { getProjectDetail, updateProject, deleteProject, moveConversations, listUnassignedConversations } from "@/lib/projects/service";
3 +import { projectBodySchema, projectActionSchema } from "@/lib/projects/schemas";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +type P = { id: string };
8 +
9 +/** GET /api/projects/:id → { project, conversations, files, prompts, stats } ; `?unassigned=1` → { conversations } outside any project. */
10 +export const GET = withUser<P>(async ({ req, user }, { id }) => {
11 + if (new URL(req.url).searchParams.get("unassigned") === "1") return json({ conversations: await listUnassignedConversations(user.id) });
12 + return json(await getProjectDetail(user.id, id));
13 +});
14 +
15 +/** PATCH /api/projects/:id (any subset of the create body) → { project } */
16 +export const PATCH = withUser<P>(async ({ req, user }, { id }) => {
17 + const body = await parseBody(req, projectBodySchema.partial());
18 + return json({ project: await updateProject(user.id, id, body) });
19 +});
20 +
21 +/** POST /api/projects/:id { action: "add-conversations" | "remove-conversations", conversationIds } → { moved } */
22 +export const POST = withUser<P>(async ({ req, user }, { id }) => {
23 + const body = await parseBody(req, projectActionSchema);
24 + const moved = await moveConversations(user.id, body.conversationIds, body.action === "add-conversations" ? id : null);
25 + return json({ moved });
26 +});
27 +
28 +/** DELETE /api/projects/:id — conversations are kept (project_id → null); files cascade; prompts are detached. */
29 +export const DELETE = withUser<P>(async ({ user }, { id }) => {
30 + await deleteProject(user.id, id);
31 + return json({ ok: true });
32 +});
added src/app/api/projects/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +import { withUser, parseBody, json } from "@/lib/api";
2 +import { listProjects, createProject } from "@/lib/projects/service";
3 +import { projectBodySchema } from "@/lib/projects/schemas";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +/** GET /api/projects?archived=1 → { projects } (non-archived by default; each with conversation/file/prompt counts). */
8 +export const GET = withUser(async ({ req, user }) => {
9 + const includeArchived = new URL(req.url).searchParams.get("archived") === "1";
10 + return json({ projects: await listProjects(user.id, { includeArchived }) });
11 +});
12 +
13 +/** POST /api/projects → 201 { project } */
14 +export const POST = withUser(async ({ req, user }) => {
15 + const body = await parseBody(req, projectBodySchema);
16 + return json({ project: await createProject(user.id, body) }, { status: 201 });
17 +});
added src/app/api/prompts/[id]/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { withUser, parseBody, json } from "@/lib/api";
2 +import { getPrompt, updatePrompt, deletePrompt, toPublicPrompt } from "@/lib/prompts/service";
3 +import { promptBodySchema } from "@/lib/prompts/schemas";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +type P = { id: string };
8 +
9 +/** GET /api/prompts/:id → { prompt } */
10 +export const GET = withUser<P>(async ({ user }, { id }) => json({ prompt: toPublicPrompt(await getPrompt(user.id, id)) }));
11 +
12 +/** PATCH /api/prompts/:id (any subset of the create body) → { prompt } */
13 +export const PATCH = withUser<P>(async ({ req, user }, { id }) => {
14 + const body = await parseBody(req, promptBodySchema.partial());
15 + return json({ prompt: await updatePrompt(user.id, id, body) });
16 +});
17 +
18 +export const DELETE = withUser<P>(async ({ user }, { id }) => {
19 + await deletePrompt(user.id, id);
20 + return json({ ok: true });
21 +});
added src/app/api/prompts/[id]/use/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +import { withUser, parseBody, json } from "@/lib/api";
2 +import { usePrompt } from "@/lib/prompts/service";
3 +import { promptUseSchema } from "@/lib/prompts/schemas";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +type P = { id: string };
8 +
9 +/**
10 + * POST /api/prompts/:id/use { variables?: Record<string, string> }
11 + * → { prompt, kind, rendered, text, systemPrompt, schema, defaultModelKey, missing, defaulted }
12 + * Increments `uses`, stamps `lastUsedAt` and renders `{{variables}}` (values → declared defaults → "").
13 + */
14 +export const POST = withUser<P>(async ({ req, user }, { id }) => {
15 + const body = await parseBody(req, promptUseSchema);
16 + return json(await usePrompt(user.id, id, body.variables ?? {}));
17 +});
added src/app/api/prompts/route.ts +31 −0
@@ -0,0 +1,31 @@
1 +import { withUser, parseBody, json } from "@/lib/api";
2 +import { listPrompts, createPrompt, PROMPT_KINDS, type PromptKind } from "@/lib/prompts/service";
3 +import { promptBodySchema } from "@/lib/prompts/schemas";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +/**
8 + * GET /api/prompts?q=&kind=&folder=<name|none>&tag=&favorite=1&projectId=<id|none>&limit=
9 + * → { prompts, folders, tags, total }. `q` matches name, description, content and tags (used by the command palette).
10 + */
11 +export const GET = withUser(async ({ req, user }) => {
12 + const p = new URL(req.url).searchParams;
13 + const kind = p.get("kind");
14 + return json(
15 + await listPrompts(user.id, {
16 + q: p.get("q")?.trim() || undefined,
17 + kind: kind && (PROMPT_KINDS as readonly string[]).includes(kind) ? (kind as PromptKind) : undefined,
18 + folder: p.get("folder") ?? undefined,
19 + tag: p.get("tag") ?? undefined,
20 + favorite: p.get("favorite") === "1",
21 + projectId: p.get("projectId") ?? undefined,
22 + limit: p.get("limit") ? Number(p.get("limit")) : undefined,
23 + }),
24 + );
25 +});
26 +
27 +/** POST /api/prompts → 201 { prompt } */
28 +export const POST = withUser(async ({ req, user }) => {
29 + const body = await parseBody(req, promptBodySchema);
30 + return json({ prompt: await createPrompt(user.id, body) }, { status: 201 });
31 +});
added src/app/api/providers/test/route.ts +26 −0
@@ -0,0 +1,26 @@
1 +import { z } from "zod";
2 +import { withUser, parseBody, json, ApiError } from "@/lib/api";
3 +import { validateConnection, getDecryptedKey, listConnections } from "@/lib/providers/keys";
4 +import { syncProvider } from "@/lib/ai/registry";
5 +import { isProviderId } from "@/lib/ai/core/types";
6 +import { LIMITS } from "@/lib/rate-limit";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +/**
11 + * POST /api/providers/test `{ provider }` — re-validate the stored (encrypted) key against the provider.
12 + * → `{ ok, latencyMs, modelsAvailable, error?, errorCode?, connections }`. The key never leaves the server.
13 + */
14 +export const POST = withUser(
15 + async ({ req, user }) => {
16 + const body = await parseBody(req, z.object({ provider: z.string().max(40) }));
17 + if (!isProviderId(body.provider) || body.provider === "custom") throw new ApiError(400, "Unknown provider", "BAD_REQUEST");
18 + const res = await validateConnection(user.id, body.provider);
19 + if (res.ok) {
20 + const key = await getDecryptedKey(user.id, body.provider);
21 + if (key) void syncProvider(body.provider, key, "user").catch(() => {});
22 + }
23 + return json({ ok: res.ok, latencyMs: res.latencyMs, modelsAvailable: res.modelsAvailable ?? null, error: res.error?.message, errorCode: res.error?.code, connections: await listConnections(user.id) });
24 + },
25 + { limit: { ...LIMITS.keyValidate, key: "provider-test" } },
26 +);
added src/app/api/public/models/route.ts +56 −0
@@ -0,0 +1,56 @@
1 +import { NextResponse } from "next/server";
2 +import { eq } from "drizzle-orm";
3 +import { getDb, models } from "@/db";
4 +import { curatePublicModels, PUBLIC_MODELS_MAX } from "@/lib/marketing/public-models";
5 +import { log } from "@/lib/log";
6 +
7 +export const dynamic = "force-dynamic";
8 +
9 +const CACHE = "public, max-age=600, s-maxage=600, stale-while-revalidate=300";
10 +
11 +/**
12 + * GET /api/public/models — unauthenticated, cached 10 minutes.
13 + * A compact list (≤ 40) of active/preview models from the registry for the marketing site:
14 + * key, displayName, provider, contextTokens, inputPerMillion, outputPerMillion, status, firstSeenAt.
15 + * No user data, no favorites, no keys — the same facts the public /models page shows.
16 + */
17 +export async function GET() {
18 + try {
19 + const rows = await getDb()
20 + .select({
21 + key: models.key,
22 + displayName: models.displayName,
23 + provider: models.provider,
24 + limits: models.limits,
25 + pricing: models.pricing,
26 + status: models.status,
27 + sortWeight: models.sortWeight,
28 + firstSeenAt: models.firstSeenAt,
29 + hidden: models.hidden,
30 + })
31 + .from(models)
32 + .where(eq(models.hidden, false));
33 +
34 + const list = curatePublicModels(
35 + rows.map((r) => ({
36 + key: r.key,
37 + displayName: r.displayName,
38 + provider: r.provider,
39 + contextTokens: r.limits?.contextTokens ?? null,
40 + inputPerMillion: r.pricing?.inputPerMillion ?? null,
41 + outputPerMillion: r.pricing?.outputPerMillion ?? null,
42 + status: r.status,
43 + sortWeight: r.sortWeight,
44 + firstSeenAt: r.firstSeenAt,
45 + hidden: r.hidden,
46 + })),
47 + new Date(),
48 + PUBLIC_MODELS_MAX,
49 + );
50 +
51 + return NextResponse.json({ models: list, total: rows.length, generatedAt: new Date().toISOString() }, { headers: { "Cache-Control": CACHE } });
52 + } catch (e) {
53 + log.warn("public models failed", { error: (e as Error).message });
54 + return NextResponse.json({ models: [], total: 0, generatedAt: new Date().toISOString() }, { status: 503, headers: { "Cache-Control": "public, max-age=60" } });
55 + }
56 +}
modified src/app/api/search/route.ts +19 −21
@@ -1,31 +1,29 @@
1 1 import { withUser, json } from "@/lib/api";
2 −import { searchEverything } from "@/lib/conversations/service";
3 −import { listRegistryModels } from "@/lib/ai/registry";
4 −import { getDb, promptPresets, modelPresets } from "@/db";
5 −import { and, eq, ilike, or } from "drizzle-orm";
2 +import { searchAll, SEARCH_GROUPS, type SearchGroup } from "@/lib/search/service";
6 3 import { LIMITS } from "@/lib/rate-limit";
7 4
8 5 export const dynamic = "force-dynamic";
9 6
7 +/**
8 + * GET /api/search?q=<query>&limit=12&cursor=<opaque>&groups=conversations,messages
9 + *
10 + * Query language (see src/lib/search/query.ts): free text, "quoted phrases", model:, provider:, project:, folder:,
11 + * after:, before:, role:user|assistant, is:pinned|archived|shared. Response: SearchResponse (grouped hits,
12 + * `query.terms` for client-side highlighting, `nextCursor` for conversations/messages pagination).
13 + */
10 14 export const GET = withUser(
11 15 async ({ req, user }) => {
12 − const q = (new URL(req.url).searchParams.get("q") ?? "").trim().slice(0, 200);
13 − if (q.length < 2) return json({ conversations: [], messages: [], models: [], prompts: [], presets: [] });
14 − const [conv, models, prompts, presets] = await Promise.all([
15 − searchEverything(user.id, q, 12),
16 − listRegistryModels().then((ms) => ms.filter((m) => (m.displayName + " " + m.id + " " + m.provider).toLowerCase().includes(q.toLowerCase())).slice(0, 8)),
17 − getDb()
18 − .select({ id: promptPresets.id, name: promptPresets.name, description: promptPresets.description })
19 − .from(promptPresets)
20 − .where(and(eq(promptPresets.userId, user.id), or(ilike(promptPresets.name, `%${q}%`), ilike(promptPresets.systemPrompt, `%${q}%`))))
21 − .limit(6),
22 − getDb()
23 − .select({ id: modelPresets.id, name: modelPresets.name, description: modelPresets.description, modelKey: modelPresets.modelKey })
24 − .from(modelPresets)
25 − .where(and(eq(modelPresets.userId, user.id), ilike(modelPresets.name, `%${q}%`)))
26 − .limit(6),
27 − ]);
28 − return json({ ...conv, models: models.map((m) => ({ key: m.key, displayName: m.displayName, provider: m.provider })), prompts, presets });
16 + const sp = new URL(req.url).searchParams;
17 + const q = (sp.get("q") ?? "").trim().slice(0, 300);
18 + const limitRaw = Number(sp.get("limit") ?? 12);
19 + const limit = Number.isFinite(limitRaw) ? limitRaw : 12;
20 + const cursor = sp.get("cursor");
21 + const groups = (sp.get("groups") ?? "")
22 + .split(",")
23 + .map((g) => g.trim())
24 + .filter((g): g is SearchGroup => (SEARCH_GROUPS as readonly string[]).includes(g));
25 + const res = await searchAll(user.id, q, { limit, cursor, groups });
26 + return json(res);
29 27 },
30 28 { limit: { ...LIMITS.search, key: "search" } },
31 29 );
added src/app/api/shares/route.ts +19 −0
@@ -0,0 +1,19 @@
1 +import { withUser, json, ApiError } from "@/lib/api";
2 +import { listShares, revokeShareById } from "@/lib/conversations/service";
3 +import { LIMITS } from "@/lib/rate-limit";
4 +
5 +export const dynamic = "force-dynamic";
6 +
7 +/** GET /api/shares → `{ shares: ShareLinkItem[] }` — every active public link of the user (Settings → Data). */
8 +export const GET = withUser(async ({ user }) => json({ shares: await listShares(user.id) }), { limit: { ...LIMITS.search, key: "shares-list" } });
9 +
10 +/** DELETE /api/shares?id=<shareId> → `{ ok: true }` — revoke one link. */
11 +export const DELETE = withUser(
12 + async ({ req, user }) => {
13 + const id = new URL(req.url).searchParams.get("id")?.trim();
14 + if (!id) throw new ApiError(400, "Missing share id");
15 + await revokeShareById(user.id, id);
16 + return json({ ok: true });
17 + },
18 + { limit: { ...LIMITS.share, key: "shares-revoke" } },
19 +);
added src/app/api/usage/export.csv/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { withUser } from "@/lib/api";
2 +import { usageRecordsForExport, usageCsv, usageQueryFromUrl } from "@/lib/usage/service";
3 +
4 +export const dynamic = "force-dynamic";
5 +
6 +/** GET /api/usage/export.csv?<same params as /api/usage> → `text/csv` attachment of the raw usage records (max 50 000 rows). */
7 +export const GET = withUser(async ({ req, user }) => {
8 + const { range, rows } = await usageRecordsForExport(user.id, usageQueryFromUrl(req.url));
9 + const from = range.from ? range.from.toISOString().slice(0, 10) : "all";
10 + const to = new Date(range.to.getTime() - 1).toISOString().slice(0, 10);
11 + const body = usageCsv(rows);
12 + return new Response(body, {
13 + status: 200,
14 + headers: {
15 + "Content-Type": "text/csv; charset=utf-8",
16 + "Content-Disposition": `attachment; filename="polyllm-usage-${from}_${to}.csv"`,
17 + "Cache-Control": "no-store",
18 + "X-Row-Count": String(rows.length),
19 + },
20 + });
21 +});
modified src/app/api/usage/route.ts +6 −6
@@ -1,10 +1,10 @@
1 1 import { withUser, json } from "@/lib/api";
2 −import { usageSummary, type UsageRange } from "@/lib/usage/service";
2 +import { usageSummary, usageQueryFromUrl } from "@/lib/usage/service";
3 3
4 4 export const dynamic = "force-dynamic";
5 5
6 −export const GET = withUser(async ({ req, user }) => {
7 − const r = new URL(req.url).searchParams.get("range");
8 − const range: UsageRange = r === "today" || r === "7d" || r === "30d" || r === "all" ? r : "30d";
9 − return json(await usageSummary(user.id, range));
10 −});
6 +/**
7 + * GET /api/usage?range=today|7d|30d|90d|custom|all&from=YYYY-MM-DD&to=YYYY-MM-DD&tz=<IANA>&provider=&modelKey=&projectId=
8 + * → `UsageSummary` (see lib/usage/service.ts): kpis, gap-filled series, byProvider, byModel, projection, savings, recent, facets.
9 + */
10 +export const GET = withUser(async ({ req, user }) => json(await usageSummary(user.id, usageQueryFromUrl(req.url))));
added src/app/app/arena/scoreboard/page.tsx +7 −0
@@ -0,0 +1,7 @@
1 +import { ScoreboardView } from "@/components/arena/scoreboard-view";
2 +
3 +export const metadata = { title: "Arena scoreboard" };
4 +
5 +export default function ArenaScoreboardPage() {
6 + return <ScoreboardView />;
7 +}
added src/app/app/library/page.tsx +12 −0
@@ -0,0 +1,12 @@
1 +import { Suspense } from "react";
2 +import { LibraryView } from "@/components/library/library-view";
3 +
4 +export const metadata = { title: "Library" };
5 +
6 +export default function LibraryPage() {
7 + return (
8 + <Suspense>
9 + <LibraryView />
10 + </Suspense>
11 + );
12 +}
added src/app/app/models/compare/page.tsx +104 −0
@@ -0,0 +1,104 @@
1 +"use client";
2 +import * as React from "react";
3 +import Link from "next/link";
4 +import { useRouter, useSearchParams } from "next/navigation";
5 +import { ArrowLeft, GitCompareArrows, Menu, Swords, X } from "lucide-react";
6 +import { useApp } from "@/components/app/store";
7 +import { Button } from "@/components/ui/button";
8 +import { EmptyState } from "@/components/ui/misc";
9 +import { ProviderIcon } from "@/components/brand/provider-icon";
10 +import { ModelSelector } from "@/components/chat/model-selector";
11 +import { useBadgeContext } from "@/components/chat/model-badges";
12 +import { CompareTable } from "@/components/models/compare-table";
13 +import { MAX_COMPARE } from "@/lib/models/slug";
14 +
15 +export default function CompareModelsPage() {
16 + return (
17 + <React.Suspense fallback={null}>
18 + <CompareInner />
19 + </React.Suspense>
20 + );
21 +}
22 +
23 +function CompareInner() {
24 + const router = useRouter();
25 + const params = useSearchParams();
26 + const { models, modelsByKey, connectedProviders, setSidebarOpen } = useApp();
27 + const ctx = useBadgeContext();
28 + const keys = React.useMemo(() => [...new Set((params.get("m") ?? "").split(",").map((s) => s.trim()).filter(Boolean))].slice(0, MAX_COMPARE), [params]);
29 + const selected = React.useMemo(() => new Set(keys), [keys]);
30 + const chosen = React.useMemo(() => keys.map((k) => modelsByKey.get(k)).filter((m): m is NonNullable<typeof m> => Boolean(m)), [keys, modelsByKey]);
31 + const unknown = keys.filter((k) => !modelsByKey.has(k));
32 +
33 + const setKeys = (next: string[]) => router.replace(next.length ? `/app/models/compare?m=${encodeURIComponent(next.join(","))}` : "/app/models/compare");
34 + const toggle = (k: string) => setKeys(selected.has(k) ? keys.filter((x) => x !== k) : keys.length >= MAX_COMPARE ? keys : [...keys, k]);
35 +
36 + return (
37 + <div className="flex h-full min-h-0 flex-col">
38 + <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 md:hidden">
39 + <Button variant="ghost" size="icon-sm" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">
40 + <Menu />
41 + </Button>
42 + <h1 className="flex-1 truncate text-[15px] font-semibold">Compare models</h1>
43 + <Button variant="ghost" size="icon-sm" asChild aria-label="Back to models">
44 + <Link href="/app/models">
45 + <ArrowLeft />
46 + </Link>
47 + </Button>
48 + </header>
49 + <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
50 + <div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6 md:py-6 lg:px-8">
51 + <div className="hidden items-end justify-between gap-4 md:flex">
52 + <div>
53 + <Link href="/app/models" className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted hover:text-fg">
54 + <ArrowLeft className="size-3.5" /> Models
55 + </Link>
56 + <h1 className="mt-1 text-2xl font-semibold tracking-tight">Compare models</h1>
57 + <p className="mt-1 text-sm text-fg-muted">Pricing, context, capabilities and supported parameters side by side. Up to {MAX_COMPARE} models.</p>
58 + </div>
59 + {chosen.length >= 2 ? (
60 + <Button asChild>
61 + <Link href={`/app/arena?models=${encodeURIComponent(chosen.map((m) => m.key).join(","))}`}>
62 + <Swords /> Try {chosen.length === 2 ? "both" : "all"} in Arena
63 + </Link>
64 + </Button>
65 + ) : null}
66 + </div>
67 +
68 + {/* Selection chips */}
69 + <div className="mt-0 flex flex-wrap items-center gap-2 md:mt-5">
70 + {chosen.map((m) => (
71 + <span key={m.key} className="inline-flex h-9 items-center gap-1.5 rounded-full bg-bg-muted pl-3 pr-1 text-[13px]">
72 + <ProviderIcon provider={m.provider} size={13} />
73 + <span className="max-w-[160px] truncate">{m.displayName}</span>
74 + <button type="button" onClick={() => toggle(m.key)} className="tap rounded-full p-1.5 text-fg-subtle hover:text-fg" aria-label={`Remove ${m.displayName}`}>
75 + <X className="size-3.5" />
76 + </button>
77 + </span>
78 + ))}
79 + {keys.length < MAX_COMPARE && models.length ? <ModelSelector value={null} multiple selected={selected} onToggle={toggle} allowDisconnected buttonLabel={chosen.length ? "Add model" : "Choose models"} size="md" className="h-9 rounded-full" /> : null}
80 + </div>
81 + {unknown.length ? <p className="mt-2 text-[12px] text-warning">Unknown model key{unknown.length > 1 ? "s" : ""}: {unknown.join(", ")}</p> : null}
82 +
83 + <div className="mt-6">
84 + {chosen.length < 2 ? (
85 + <EmptyState icon={<GitCompareArrows />} title={chosen.length === 1 ? "Add one more model" : "Pick two to four models"} description="Compare pricing, context windows, capabilities and supported parameters side by side." />
86 + ) : (
87 + <CompareTable models={chosen} ctx={ctx} connected={(m) => connectedProviders.has(m.provider)} />
88 + )}
89 + </div>
90 +
91 + {chosen.length >= 2 ? (
92 + <div className="mt-6 md:hidden">
93 + <Button asChild className="h-11 w-full">
94 + <Link href={`/app/arena?models=${encodeURIComponent(chosen.map((m) => m.key).join(","))}`}>
95 + <Swords /> Try {chosen.length === 2 ? "both" : "all"} in Arena
96 + </Link>
97 + </Button>
98 + </div>
99 + ) : null}
100 + </div>
101 + </main>
102 + </div>
103 + );
104 +}
modified src/app/app/models/page.tsx +403 −423
@@ -1,35 +1,56 @@
1 1 "use client";
2 2 import * as React from "react";
3 3 import { useRouter } from "next/navigation";
4 −import { Search, RefreshCw, Star, Boxes, Eye, Brain, Wrench, Braces, Globe, Paperclip, MessageSquare, AlertTriangle, Info, Filter, X } from "lucide-react";
4 +import { useVirtualizer } from "@tanstack/react-virtual";
5 +import { ArrowDown, ArrowUp, ArrowUpDown, Boxes, Braces, Brain, Check, DollarSign, Eye, FileText, GitCompareArrows, Globe, Info, Leaf, Menu, RefreshCw, Search, Sparkles, Star, Wrench, X, Zap } from "lucide-react";
5 6 import { PageHeader, EmptyState, Skeleton } from "@/components/ui/misc";
6 7 import { Button } from "@/components/ui/button";
7 8 import { Input } from "@/components/ui/input";
8 9 import { Badge } from "@/components/ui/badge";
9 10 import { Switch } from "@/components/ui/switch";
11 +import { Segmented, ChipRow } from "@/components/ui/segmented";
10 12 import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
11 −import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter } from "@/components/ui/dialog";
12 13 import { Tooltip } from "@/components/ui/tooltip";
13 14 import { toast } from "@/components/ui/toast";
14 15 import { ProviderIcon } from "@/components/brand/provider-icon";
15 16 import { useApp } from "@/components/app/store";
16 −import { api } from "@/lib/client/api";
17 +import { api, useApi } from "@/lib/client/api";
17 18 import { PROVIDERS, PROVIDER_ORDER, providerName } from "@/lib/client/providers";
19 +import { errorMessage } from "@/lib/client/humanize";
20 +import { useIsMobile } from "@/lib/client/hooks";
21 +import type { ModelsResponse, PolyModel, ProviderId, ModelCapabilities } from "@/lib/client/types";
22 +import { ModelProfile } from "@/components/models/model-profile";
23 +import { Meter } from "@/components/models/meter";
24 +import { CapabilityGlyphs, ModelBadges, StatusBadge, useBadgeContext } from "@/components/chat/model-badges";
25 +import { buildBadgeContext, isCheapModel, isFastModel, isNewModel, isOpenWeightsModel, lifecycleStatus, sortWeightOf, speedTier, type BadgeContext } from "@/lib/models/badges";
26 +import { searchModels, parseSearchQuery, isEmptyIntent } from "@/lib/models/search";
27 +import { formatPrice } from "@/lib/models/format";
18 28 import { formatTokens, cn } from "@/lib/utils";
19 −import { errorMessage, formatDate } from "@/lib/client/humanize";
20 −import type { PolyModel, ProviderId, ModelCapabilities } from "@/lib/client/types";
21 29
22 30 type CapKey = keyof ModelCapabilities;
23 −const CAP_FILTERS: { key: CapKey; label: string; icon: React.ReactNode }[] = [
24 − { key: "reasoning", label: "Reasoning", icon: <Brain /> },
25 − { key: "vision", label: "Vision", icon: <Eye /> },
26 − { key: "tools", label: "Tools", icon: <Wrench /> },
27 − { key: "structuredOutput", label: "Structured output", icon: <Braces /> },
28 − { key: "webSearch", label: "Web search", icon: <Globe /> },
29 − { key: "files", label: "Files", icon: <Paperclip /> },
31 +type Flag = "fast" | "cheap" | "open" | "new";
32 +type Filter = CapKey | Flag;
33 +type SortKey = "default" | "name" | "provider" | "input" | "output" | "context" | "maxOut" | "reasoning" | "vision" | "tools" | "speed" | "status";
34 +type StatusFilter = "all" | "active" | "preview" | "deprecated";
35 +
36 +const FILTERS: { value: Filter; label: string; icon: React.ReactNode }[] = [
37 + { value: "reasoning", label: "Reasoning", icon: <Brain /> },
38 + { value: "vision", label: "Vision", icon: <Eye /> },
39 + { value: "tools", label: "Tools", icon: <Wrench /> },
40 + { value: "structuredOutput", label: "JSON", icon: <Braces /> },
41 + { value: "webSearch", label: "Web", icon: <Globe /> },
42 + { value: "files", label: "PDF", icon: <FileText /> },
43 + { value: "fast", label: "Fast", icon: <Zap /> },
44 + { value: "cheap", label: "Cheap", icon: <DollarSign /> },
45 + { value: "open", label: "Open source", icon: <Leaf /> },
46 + { value: "new", label: "New", icon: <Sparkles /> },
30 47 ];
48 +const FLAGS = new Set<string>(["fast", "cheap", "open", "new"]);
31 49
32 −const STATUS_VARIANT: Record<PolyModel["status"], "success" | "info" | "warning" | "default"> = { active: "success", preview: "info", deprecated: "warning", unknown: "default" };
50 +const SORT_LABEL: Record<SortKey, string> = { default: "Recommended", name: "Model", provider: "Provider", input: "Input price", output: "Output price", context: "Context", maxOut: "Max output", reasoning: "Reasoning", vision: "Vision", tools: "Tools", speed: "Speed tier", status: "Status" };
51 +const SPEED_RANK = { fast: 0, standard: 1, frontier: 2 };
52 +const STATUS_RANK: Record<string, number> = { new: 0, active: 1, preview: 2, unknown: 3, unavailable: 4, retiring: 5, deprecated: 6 };
53 +const MAX_COMPARE = 4;
33 54
34 55 interface SyncResult {
35 56 provider: ProviderId;
@@ -41,58 +62,118 @@ interface SyncResult {
41 62 error?: string;
42 63 }
43 64
44 −function shutdownDate(m: PolyModel): string | null {
45 − const v = m.metadata?.shutdownDate;
46 − return typeof v === "string" && v ? v : null;
47 −}
48 −
49 −function price(v: number | undefined | null): string {
50 − if (v === undefined || v === null) return "—";
51 − return v < 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(v % 1 === 0 ? 0 : 2)}`;
65 +function matchesFlag(m: PolyModel, f: Flag, ctx: BadgeContext): boolean {
66 + switch (f) {
67 + case "fast":
68 + return isFastModel(m, ctx);
69 + case "cheap":
70 + return isCheapModel(m, ctx);
71 + case "open":
72 + return isOpenWeightsModel(m);
73 + case "new":
74 + return isNewModel(m, ctx);
75 + }
52 76 }
53 77
54 78 export default function ModelsPage() {
55 79 const router = useRouter();
56 − const { models, loadingModels, favorites, toggleFavorite, connectedProviders, refreshModels, setSelectedModelKey } = useApp();
80 + const isMobile = useIsMobile();
81 + const { models: storeModels, loadingModels, favorites, toggleFavorite, connectedProviders, refreshModels, setSidebarOpen } = useApp();
82 + const baseCtx = useBadgeContext();
57 83 const [q, setQ] = React.useState("");
84 + const [filters, setFilters] = React.useState<Filter[]>([]);
58 85 const [provider, setProvider] = React.useState<"all" | ProviderId>("all");
59 − const [status, setStatus] = React.useState<"all" | PolyModel["status"]>("all");
60 − const [caps, setCaps] = React.useState<Set<CapKey>>(new Set());
86 + const [status, setStatus] = React.useState<StatusFilter>("all");
61 87 const [connectedOnly, setConnectedOnly] = React.useState(false);
62 88 const [favOnly, setFavOnly] = React.useState(false);
63 − const [syncing, setSyncing] = React.useState(false);
89 + const [sort, setSort] = React.useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "default", dir: "desc" });
90 + const [selection, setSelection] = React.useState<string[]>([]);
64 91 const [detail, setDetail] = React.useState<PolyModel | null>(null);
92 + const [syncing, setSyncing] = React.useState(false);
65 93 const [filtersOpen, setFiltersOpen] = React.useState(false);
66 94
95 + // Deprecated models are excluded from the shared store; fetch them only when asked.
96 + const withDeprecated = status === "deprecated";
97 + const depQ = useApi<ModelsResponse>(withDeprecated ? "/api/models?deprecated=1" : null);
98 + const models = React.useMemo(() => (withDeprecated && depQ.data ? depQ.data.models : storeModels), [withDeprecated, depQ.data, storeModels]);
99 + const ctx = React.useMemo(() => (models === storeModels ? baseCtx : buildBadgeContext(models, baseCtx.now)), [models, storeModels, baseCtx]);
100 +
101 + const intent = React.useMemo(() => parseSearchQuery(q), [q]);
102 + const searching = q.trim().length > 0 && !isEmptyIntent(intent);
103 +
67 104 const filtered = React.useMemo(() => {
68 − const s = q.trim().toLowerCase();
69 − return models
105 + let list: PolyModel[] = searching ? searchModels(models, intent, { ctx, favorites, providerNames: Object.fromEntries(PROVIDER_ORDER.map((p) => [p, PROVIDERS[p].name])) }).map((r) => r.model) : models;
106 + list = list
70 107 .filter((m) => (provider === "all" ? true : m.provider === provider))
71 − .filter((m) => (status === "all" ? true : m.status === status))
108 + .filter((m) => (status === "all" ? true : status === "deprecated" ? m.status === "deprecated" : m.status === status))
72 109 .filter((m) => (connectedOnly ? connectedProviders.has(m.provider) : true))
73 110 .filter((m) => (favOnly ? favorites.has(m.key) : true))
74 − .filter((m) => [...caps].every((c) => m.capabilities[c]))
75 − .filter((m) => (s ? [m.displayName, m.id, m.key, m.family ?? "", providerName(m.provider)].some((x) => x.toLowerCase().includes(s)) : true))
76 − .sort((a, b) => Number(favorites.has(b.key)) - Number(favorites.has(a.key)) || PROVIDER_ORDER.indexOf(a.provider) - PROVIDER_ORDER.indexOf(b.provider));
77 − }, [models, q, provider, status, connectedOnly, favOnly, caps, connectedProviders, favorites]);
111 + .filter((m) => filters.every((f) => (FLAGS.has(f) ? matchesFlag(m, f as Flag, ctx) : m.capabilities[f as CapKey])));
112 + const dir = sort.dir === "asc" ? 1 : -1;
113 + const num = (v: number | undefined | null, missing: number) => (typeof v === "number" ? v : missing);
114 + const cmp = (a: PolyModel, b: PolyModel): number => {
115 + switch (sort.key) {
116 + case "default":
117 + return searching ? 0 : Number(favorites.has(b.key)) - Number(favorites.has(a.key)) || PROVIDER_ORDER.indexOf(a.provider) - PROVIDER_ORDER.indexOf(b.provider) || sortWeightOf(b) - sortWeightOf(a);
118 + case "name":
119 + return dir * a.displayName.localeCompare(b.displayName);
120 + case "provider":
121 + return dir * (PROVIDER_ORDER.indexOf(a.provider) - PROVIDER_ORDER.indexOf(b.provider)) || sortWeightOf(b) - sortWeightOf(a);
122 + case "input":
123 + return dir * (num(a.pricing?.inputPerMillion, dir > 0 ? 1e9 : -1) - num(b.pricing?.inputPerMillion, dir > 0 ? 1e9 : -1));
124 + case "output":
125 + return dir * (num(a.pricing?.outputPerMillion, dir > 0 ? 1e9 : -1) - num(b.pricing?.outputPerMillion, dir > 0 ? 1e9 : -1));
126 + case "context":
127 + return dir * (num(a.limits?.contextTokens, 0) - num(b.limits?.contextTokens, 0));
128 + case "maxOut":
129 + return dir * (num(a.limits?.maxOutputTokens, 0) - num(b.limits?.maxOutputTokens, 0));
130 + case "reasoning":
131 + return dir * (Number(a.capabilities.reasoning) - Number(b.capabilities.reasoning)) || sortWeightOf(b) - sortWeightOf(a);
132 + case "vision":
133 + return dir * (Number(a.capabilities.vision) - Number(b.capabilities.vision)) || sortWeightOf(b) - sortWeightOf(a);
134 + case "tools":
135 + return dir * (Number(a.capabilities.tools) - Number(b.capabilities.tools)) || sortWeightOf(b) - sortWeightOf(a);
136 + case "speed":
137 + return dir * (SPEED_RANK[speedTier(a, ctx)] - SPEED_RANK[speedTier(b, ctx)]) || sortWeightOf(b) - sortWeightOf(a);
138 + case "status":
139 + return dir * (STATUS_RANK[lifecycleStatus(a, { ctx, connected: connectedProviders.has(a.provider) }).status] - STATUS_RANK[lifecycleStatus(b, { ctx, connected: connectedProviders.has(b.provider) }).status]) || sortWeightOf(b) - sortWeightOf(a);
140 + }
141 + };
142 + return sort.key === "default" && searching ? list : [...list].sort(cmp);
143 + }, [models, searching, intent, ctx, favorites, provider, status, connectedOnly, favOnly, filters, sort, connectedProviders]);
144 +
145 + const maxIn = React.useMemo(() => Math.max(0, ...filtered.map((m) => m.pricing?.inputPerMillion ?? 0)), [filtered]);
146 + const maxOut = React.useMemo(() => Math.max(0, ...filtered.map((m) => m.pricing?.outputPerMillion ?? 0)), [filtered]);
147 + const maxCtx = React.useMemo(() => Math.max(0, ...filtered.map((m) => m.limits?.contextTokens ?? 0)), [filtered]);
78 148
79 − const activeFilterCount = (provider !== "all" ? 1 : 0) + (status !== "all" ? 1 : 0) + caps.size + (connectedOnly ? 1 : 0) + (favOnly ? 1 : 0);
149 + const activeFilterCount = (provider !== "all" ? 1 : 0) + (status !== "all" ? 1 : 0) + filters.length + (connectedOnly ? 1 : 0) + (favOnly ? 1 : 0);
80 150 const clearFilters = () => {
81 151 setProvider("all");
82 152 setStatus("all");
83 − setCaps(new Set());
153 + setFilters([]);
84 154 setConnectedOnly(false);
85 155 setFavOnly(false);
86 156 };
87 157
158 + const toggleSort = (key: SortKey) => setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "name" || key === "provider" || key === "input" || key === "output" || key === "speed" || key === "status" ? "asc" : "desc" }));
159 +
160 + const toggleSelect = (key: string) =>
161 + setSelection((prev) => {
162 + if (prev.includes(key)) return prev.filter((k) => k !== key);
163 + if (prev.length >= MAX_COMPARE) {
164 + toast.warning(`Compare up to ${MAX_COMPARE} models`, "Deselect one to add another.");
165 + return prev;
166 + }
167 + return [...prev, key];
168 + });
169 +
88 170 const sync = async () => {
89 171 setSyncing(true);
90 172 try {
91 173 const res = await api<{ results: SyncResult[] }>("/api/models/sync", { method: "POST", json: {} });
92 174 await refreshModels();
93 − if (res.results.length === 0) {
94 − toast.warning("Nothing to refresh", "Connect a provider first — models are listed with your own keys.");
95 − } else {
175 + if (res.results.length === 0) toast.warning("Nothing to refresh", "Connect a provider first — models are listed with your own keys.");
176 + else {
96 177 const ok = res.results.filter((r) => r.ok);
97 178 const bad = res.results.filter((r) => !r.ok);
98 179 if (ok.length) toast.success(`Refreshed ${ok.length} provider${ok.length > 1 ? "s" : ""}`, ok.map((r) => `${providerName(r.provider)}: ${r.found} found, +${r.added} / −${r.removed}`).join(" · "));
@@ -105,221 +186,256 @@ export default function ModelsPage() {
105 186 }
106 187 };
107 188
108 − const chatWith = (m: PolyModel) => {
109 − setSelectedModelKey(m.key);
110 − router.push("/app/chat");
111 − };
189 + // Virtualized list -----------------------------------------------------------
190 + const listRef = React.useRef<HTMLDivElement>(null);
191 + const virtualizer = useVirtualizer({ count: filtered.length, getScrollElement: () => listRef.current, estimateSize: () => (isMobile ? 104 : 56), overscan: 12, getItemKey: (i) => filtered[i].key });
112 192
113 − const toggleCap = (c: CapKey) =>
114 − setCaps((prev) => {
115 − const n = new Set(prev);
116 − if (n.has(c)) n.delete(c);
117 − else n.add(c);
118 − return n;
119 − });
193 + const loading = loadingModels || (withDeprecated && depQ.isLoading);
194 + const providerChips = [{ value: "all" as const, label: "All providers" }, ...PROVIDER_ORDER.map((p) => ({ value: p, label: PROVIDERS[p].shortName, icon: <ProviderIcon provider={p} size={13} /> }))];
120 195
121 196 return (
122 − <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
123 − <div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
124 − <PageHeader
125 − title="Models"
126 − description={
127 − <span className="inline-flex flex-wrap items-center gap-1.5">
128 − {models.length} models across {PROVIDER_ORDER.length} providers.
129 − <Tooltip content="Live listings from each provider (using your keys) are merged with a documented catalog that adds context windows, pricing and capability details the list APIs do not expose.">
130 − <span className="inline-flex cursor-help items-center gap-1 text-fg-subtle underline decoration-dotted underline-offset-2">
131 − <Info className="size-3.5" /> live listings + catalog
132 − </span>
133 − </Tooltip>
134 − </span>
135 − }
136 − actions={
137 − <Button variant="outline" loading={syncing} onClick={sync}>
138 − <RefreshCw /> Refresh models
139 − </Button>
140 − }
141 − />
197 + <div className="flex h-full min-h-0 flex-col">
198 + {/* Mobile bar */}
199 + <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 md:hidden">
200 + <Button variant="ghost" size="icon-sm" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">
201 + <Menu />
202 + </Button>
203 + <h1 className="flex-1 truncate text-[15px] font-semibold">Models</h1>
204 + <Button variant="ghost" size="icon-sm" onClick={sync} loading={syncing} aria-label="Refresh models">
205 + <RefreshCw />
206 + </Button>
207 + </header>
142 208
143 − {/* Toolbar */}
144 − <div className="mt-5 flex flex-col gap-2 md:flex-row md:items-center">
145 − <div className="relative flex-1">
146 − <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />
147 − <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, id or family…" className="pl-8" aria-label="Search models" />
148 − </div>
149 − <div className="flex items-center gap-2">
150 − <Button variant="outline" size="md" className="md:hidden" onClick={() => setFiltersOpen((v) => !v)} aria-expanded={filtersOpen}>
151 − <Filter /> Filters {activeFilterCount ? <Badge variant="accent">{activeFilterCount}</Badge> : null}
152 − </Button>
153 − <Select value={provider} onValueChange={(v) => setProvider(v as typeof provider)}>
154 − <SelectTrigger className="hidden w-40 md:flex" aria-label="Provider">
155 − <SelectValue />
156 − </SelectTrigger>
157 − <SelectContent>
158 − <SelectItem value="all">All providers</SelectItem>
159 − {PROVIDER_ORDER.map((p) => (
160 − <SelectItem key={p} value={p}>
161 − {PROVIDERS[p].name}
162 − </SelectItem>
163 − ))}
164 − </SelectContent>
165 − </Select>
166 − <Select value={status} onValueChange={(v) => setStatus(v as typeof status)}>
167 − <SelectTrigger className="hidden w-36 md:flex" aria-label="Status">
168 − <SelectValue />
169 − </SelectTrigger>
170 − <SelectContent>
171 − <SelectItem value="all">Any status</SelectItem>
172 − <SelectItem value="active">Active</SelectItem>
173 − <SelectItem value="preview">Preview</SelectItem>
174 − <SelectItem value="deprecated">Deprecated</SelectItem>
175 − <SelectItem value="unknown">Unknown</SelectItem>
176 − </SelectContent>
177 − </Select>
178 − </div>
179 − </div>
209 + <div className="shrink-0 border-b border-border">
210 + <div className="mx-auto w-full max-w-7xl px-4 pt-3 pb-3 sm:px-6 md:pt-6 lg:px-8">
211 + <PageHeader
212 + className="hidden md:flex"
213 + title="Models"
214 + description={
215 + <span className="inline-flex flex-wrap items-center gap-1.5">
216 + {models.length} models across {PROVIDER_ORDER.length} providers.
217 + <Tooltip content="Live listings from each provider (using your keys) are merged with a documented catalog that adds context windows, pricing and capability details the list APIs do not expose.">
218 + <span className="inline-flex cursor-help items-center gap-1 text-fg-subtle underline decoration-dotted underline-offset-2">
219 + <Info className="size-3.5" /> live listings + catalog
220 + </span>
221 + </Tooltip>
222 + </span>
223 + }
224 + actions={
225 + <Button variant="outline" loading={syncing} onClick={sync}>
226 + <RefreshCw /> Refresh models
227 + </Button>
228 + }
229 + />
180 230
181 − <div className={cn("mt-3 flex-col gap-3 md:flex md:flex-row md:flex-wrap md:items-center", filtersOpen ? "flex" : "hidden")}>
182 − <div className="flex gap-2 md:hidden">
183 − <Select value={provider} onValueChange={(v) => setProvider(v as typeof provider)}>
184 − <SelectTrigger aria-label="Provider">
185 − <SelectValue />
186 − </SelectTrigger>
187 − <SelectContent>
188 − <SelectItem value="all">All providers</SelectItem>
189 − {PROVIDER_ORDER.map((p) => (
190 − <SelectItem key={p} value={p}>
191 − {PROVIDERS[p].name}
192 − </SelectItem>
193 − ))}
194 − </SelectContent>
195 − </Select>
196 − <Select value={status} onValueChange={(v) => setStatus(v as typeof status)}>
197 − <SelectTrigger aria-label="Status">
198 − <SelectValue />
199 − </SelectTrigger>
200 − <SelectContent>
201 − <SelectItem value="all">Any status</SelectItem>
202 − <SelectItem value="active">Active</SelectItem>
203 − <SelectItem value="preview">Preview</SelectItem>
204 − <SelectItem value="deprecated">Deprecated</SelectItem>
205 − <SelectItem value="unknown">Unknown</SelectItem>
206 − </SelectContent>
207 − </Select>
208 − </div>
209 − <div className="flex flex-wrap gap-1.5" role="group" aria-label="Capabilities">
210 − {CAP_FILTERS.map((c) => {
211 − const on = caps.has(c.key);
212 − return (
213 − <button key={c.key} type="button" aria-pressed={on} onClick={() => toggleCap(c.key)} className={cn("inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium transition-colors [&_svg]:size-3.5", on ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-border-strong hover:text-fg")}>
214 − {c.icon} {c.label}
215 − </button>
216 − );
217 − })}
218 − </div>
219 − <div className="flex items-center gap-4 md:ml-auto">
220 − <label className="flex items-center gap-2 text-xs text-fg-muted">
221 − <Switch size="sm" checked={connectedOnly} onCheckedChange={setConnectedOnly} /> Connected only
222 − </label>
223 − <label className="flex items-center gap-2 text-xs text-fg-muted">
224 − <Switch size="sm" checked={favOnly} onCheckedChange={setFavOnly} /> Favorites
225 − </label>
226 − {activeFilterCount ? (
227 − <Button variant="ghost" size="xs" onClick={clearFilters}>
228 − <X /> Clear
231 + {/* Toolbar */}
232 + <div className="mt-0 flex flex-col gap-2 md:mt-5">
233 + <div className="flex items-center gap-2">
234 + <div className="relative flex-1">
235 + <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />
236 + <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={isMobile ? "Search models…" : "Search — “cheap vision”, “1M context”, “under $1/M”, “fastest gemini”…"} className="h-10 pl-8 pr-8 md:h-9" aria-label="Search models" />
237 + {q ? (
238 + <button type="button" onClick={() => setQ("")} className="tap absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-bg-muted p-1 text-fg-muted" aria-label="Clear search">
239 + <X className="size-3" />
240 + </button>
241 + ) : null}
242 + </div>
243 + <Button variant="outline" className="h-10 md:hidden" onClick={() => setFiltersOpen((v) => !v)} aria-expanded={filtersOpen}>
244 + Filters {activeFilterCount ? <Badge variant="accent">{activeFilterCount}</Badge> : null}
229 245 </Button>
246 + <Select value={sort.key} onValueChange={(v) => setSort((s) => ({ key: v as SortKey, dir: v === s.key ? s.dir : "desc" }))}>
247 + <SelectTrigger className="h-10 w-[46%] max-w-[180px] md:hidden" aria-label="Sort by">
248 + <SelectValue />
249 + </SelectTrigger>
250 + <SelectContent>
251 + {(Object.keys(SORT_LABEL) as SortKey[]).map((k) => (
252 + <SelectItem key={k} value={k}>
253 + {SORT_LABEL[k]}
254 + </SelectItem>
255 + ))}
256 + </SelectContent>
257 + </Select>
258 + </div>
259 + {searching && intent.chips.length ? (
260 + <div className="flex flex-wrap items-center gap-1">
261 + <span className="text-[11px] text-fg-subtle">Understood:</span>
262 + {intent.chips.map((c) => (
263 + <Badge key={c} variant="accent">
264 + {c}
265 + </Badge>
266 + ))}
267 + </div>
230 268 ) : null}
269 +
270 + <div className={cn("flex-col gap-2 md:flex", filtersOpen ? "flex" : "hidden")}>
271 + <ChipRow<Filter> multiple value={filters} onChange={(v) => setFilters((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]))} options={FILTERS} className="-mx-4 px-4 sm:mx-0 sm:px-0" />
272 + <div className="flex flex-col gap-2 md:flex-row md:items-center">
273 + <ChipRow<"all" | ProviderId> value={provider} onChange={setProvider} options={providerChips} className="-mx-4 px-4 sm:mx-0 sm:px-0 md:flex-1" />
274 + <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
275 + <Segmented<StatusFilter> size="sm" value={status} onChange={setStatus} ariaLabel="Status" options={[{ value: "all", label: "Any status" }, { value: "active", label: "Active" }, { value: "preview", label: "Preview" }, { value: "deprecated", label: "Deprecated" }]} />
276 + <label className="flex items-center gap-2 text-xs text-fg-muted">
277 + <Switch size="sm" checked={connectedOnly} onCheckedChange={setConnectedOnly} /> Connected only
278 + </label>
279 + <label className="flex items-center gap-2 text-xs text-fg-muted">
280 + <Switch size="sm" checked={favOnly} onCheckedChange={setFavOnly} /> Favorites
281 + </label>
282 + {activeFilterCount ? (
283 + <Button variant="ghost" size="xs" onClick={clearFilters}>
284 + <X /> Clear
285 + </Button>
286 + ) : null}
287 + </div>
288 + </div>
289 + </div>
231 290 </div>
232 291 </div>
292 + </div>
233 293
234 − {/* Results */}
235 − <div className="mt-4">
236 − {loadingModels ? (
237 − <div className="space-y-2">
294 + {/* Results */}
295 + <div ref={listRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-thin contain-scroll">
296 + <div className="mx-auto w-full max-w-7xl px-4 pb-4 sm:px-6 lg:px-8">
297 + {loading ? (
298 + <div className="mt-4 space-y-2">
238 299 {Array.from({ length: 8 }).map((_, i) => (
239 − <Skeleton key={i} className="h-12" />
300 + <Skeleton key={i} className="h-14" />
240 301 ))}
241 302 </div>
242 303 ) : models.length === 0 ? (
243 − <EmptyState icon={<Boxes />} title="No models in the registry yet" description="Connect a provider and refresh — models are discovered with your own keys." action={<Button onClick={sync} loading={syncing}>Refresh models</Button>} />
304 + <EmptyState className="mt-4" icon={<Boxes />} title="No models in the registry yet" description="Connect a provider and refresh — models are discovered with your own keys." action={<Button onClick={sync} loading={syncing}>Refresh models</Button>} />
244 305 ) : filtered.length === 0 ? (
245 − <EmptyState title="No models match" description="Try fewer filters or a different search." action={<Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>} />
306 + <EmptyState className="mt-4" title="No models match" description={searching ? "Try a capability (“vision”), a size (“1M context”), a price (“under $1/M”) or a provider." : "Try fewer filters."} action={<Button variant="outline" size="sm" onClick={() => { clearFilters(); setQ(""); }}>Clear everything</Button>} />
246 307 ) : (
247 308 <>
248 − <p className="mb-2 text-[11px] text-fg-subtle">
249 − {filtered.length} of {models.length} models
250 − </p>
251 − {/* Desktop table */}
252 − <div className="hidden overflow-hidden rounded-xl border border-border bg-bg-elevated md:block">
253 − <table className="w-full text-[13px]">
254 − <thead>
255 − <tr className="border-b border-border bg-bg-subtle/60 text-left text-[11px] font-medium uppercase tracking-wide text-fg-subtle">
256 − <th className="w-8 px-3 py-2" aria-label="Favorite" />
257 − <th className="px-2 py-2">Model</th>
258 − <th className="px-3 py-2">Provider</th>
259 − <th className="px-3 py-2 text-right">Context</th>
260 − <th className="px-3 py-2 text-right">Max out</th>
261 − <th className="px-3 py-2">Capabilities</th>
262 − <th className="px-3 py-2 text-right">$/1M in · out</th>
263 − <th className="px-3 py-2">Status</th>
264 − </tr>
265 − </thead>
266 − <tbody>
267 − {filtered.map((m) => (
268 − <ModelTableRow key={m.key} m={m} fav={favorites.has(m.key)} connected={connectedProviders.has(m.provider)} onFav={() => toggleFavorite(m.key)} onOpen={() => setDetail(m)} />
269 − ))}
270 − </tbody>
271 − </table>
309 + <div className="sticky top-0 z-10 bg-bg">
310 + <p className="pt-3 pb-1 text-[11px] text-fg-subtle md:pt-4">
311 + {filtered.length} of {models.length} models{selection.length ? ` · ${selection.length} selected` : ""}
312 + </p>
313 + {/* Desktop header row */}
314 + <div role="row" className="hidden border-b border-border pb-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-subtle md:grid md:grid-cols-[36px_minmax(0,1.6fr)_100px_88px_88px_76px_40px_40px_40px_100px_36px] lg:grid-cols-[36px_minmax(0,1.6fr)_100px_88px_88px_76px_76px_40px_40px_40px_84px_100px_36px] md:items-center md:gap-x-2">
315 + <span />
316 + <SortHeader k="name" sort={sort} onSort={toggleSort}>
317 + Model
318 + </SortHeader>
319 + <SortHeader k="provider" sort={sort} onSort={toggleSort}>
320 + Provider
321 + </SortHeader>
322 + <SortHeader k="input" sort={sort} onSort={toggleSort} align="right">
323 + Input $/1M
324 + </SortHeader>
325 + <SortHeader k="output" sort={sort} onSort={toggleSort} align="right">
326 + Output $/1M
327 + </SortHeader>
328 + <SortHeader k="context" sort={sort} onSort={toggleSort} align="right">
329 + Context
330 + </SortHeader>
331 + <SortHeader k="maxOut" sort={sort} onSort={toggleSort} align="right" className="hidden lg:flex">
332 + Max out
333 + </SortHeader>
334 + <SortHeader k="reasoning" sort={sort} onSort={toggleSort} align="center" title="Reasoning">
335 + <Brain className="size-3.5" />
336 + </SortHeader>
337 + <SortHeader k="vision" sort={sort} onSort={toggleSort} align="center" title="Vision">
338 + <Eye className="size-3.5" />
339 + </SortHeader>
340 + <SortHeader k="tools" sort={sort} onSort={toggleSort} align="center" title="Tools">
341 + <Wrench className="size-3.5" />
342 + </SortHeader>
343 + <SortHeader k="speed" sort={sort} onSort={toggleSort} className="hidden lg:flex">
344 + Speed
345 + </SortHeader>
346 + <SortHeader k="status" sort={sort} onSort={toggleSort}>
347 + Status
348 + </SortHeader>
349 + <span />
350 + </div>
351 + </div>
352 + <div role="table" aria-label="Models" style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
353 + {virtualizer.getVirtualItems().map((v) => {
354 + const m = filtered[v.index];
355 + const idx = selection.indexOf(m.key);
356 + return (
357 + <div key={v.key} data-index={v.index} ref={virtualizer.measureElement} style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${v.start}px)` }}>
358 + <ModelRow m={m} ctx={ctx} fav={favorites.has(m.key)} connected={connectedProviders.has(m.provider)} selected={idx >= 0} selectionFull={selection.length >= MAX_COMPARE} maxIn={maxIn} maxOut={maxOut} maxCtx={maxCtx} onFav={() => void toggleFavorite(m.key)} onOpen={() => setDetail(m)} onSelect={() => toggleSelect(m.key)} />
359 + </div>
360 + );
361 + })}
272 362 </div>
273 − {/* Mobile cards */}
274 − <ul className="space-y-2 md:hidden">
275 − {filtered.map((m) => (
276 − <ModelCard key={m.key} m={m} fav={favorites.has(m.key)} connected={connectedProviders.has(m.provider)} onFav={() => toggleFavorite(m.key)} onOpen={() => setDetail(m)} />
277 − ))}
278 − </ul>
279 363 </>
280 364 )}
281 365 </div>
282 366 </div>
283 367
284 − <ModelDetail model={detail} onClose={() => setDetail(null)} connected={detail ? connectedProviders.has(detail.provider) : false} fav={detail ? favorites.has(detail.key) : false} onFav={() => detail && toggleFavorite(detail.key)} onChat={() => detail && chatWith(detail)} />
285 − </main>
368 + {/* Compare bar */}
369 + {selection.length ? (
370 + <div className="shrink-0 border-t border-border bg-bg-elevated/95 px-4 py-2.5 backdrop-blur sm:px-6">
371 + <div className="mx-auto flex w-full max-w-7xl items-center gap-2">
372 + <div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto scrollbar-none">
373 + {selection.map((k) => {
374 + const m = models.find((x) => x.key === k);
375 + return (
376 + <span key={k} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-bg-muted pl-2.5 pr-1 text-[12.5px]">
377 + {m ? <ProviderIcon provider={m.provider} size={12} /> : null}
378 + <span className="max-w-[140px] truncate">{m?.displayName ?? k}</span>
379 + <button type="button" onClick={() => toggleSelect(k)} className="tap rounded-full p-1 text-fg-subtle hover:text-fg" aria-label={`Remove ${m?.displayName ?? k} from comparison`}>
380 + <X className="size-3" />
381 + </button>
382 + </span>
383 + );
384 + })}
385 + </div>
386 + <Button variant="ghost" size="sm" className="hidden sm:inline-flex" onClick={() => setSelection([])}>
387 + Clear
388 + </Button>
389 + <Button size="sm" className="h-9" disabled={selection.length < 2} onClick={() => router.push(`/app/models/compare?m=${encodeURIComponent(selection.join(","))}`)}>
390 + <GitCompareArrows /> Compare{selection.length < 2 ? " (pick 2+)" : ` ${selection.length}`}
391 + </Button>
392 + </div>
393 + </div>
394 + ) : null}
395 +
396 + <ModelProfile model={detail} open={Boolean(detail)} onOpenChange={(v) => (!v ? setDetail(null) : null)} />
397 + </div>
286 398 );
287 399 }
288 400
289 −function CapIcons({ m, max = 6 }: { m: PolyModel; max?: number }) {
290 − const on = CAP_FILTERS.filter((c) => m.capabilities[c.key]).slice(0, max);
401 +/* ------------------------------------------------------------------------------------------------ */
402 +
403 +function SortHeader({ k, sort, onSort, children, align = "left", className, title }: { k: SortKey; sort: { key: SortKey; dir: "asc" | "desc" }; onSort: (k: SortKey) => void; children: React.ReactNode; align?: "left" | "right" | "center"; className?: string; title?: string }) {
404 + const on = sort.key === k;
291 405 return (
292 − <span className="flex items-center gap-1">
293 − {on.map((c) => (
294 − <Tooltip key={c.key} content={c.label}>
295 − <span className="flex size-6 items-center justify-center rounded-md bg-bg-muted text-fg-muted [&_svg]:size-3.5">{c.icon}</span>
296 − </Tooltip>
297 − ))}
298 − {on.length === 0 ? <span className="text-xs text-fg-subtle">text only</span> : null}
299 − </span>
406 + <div role="columnheader" aria-sort={on ? (sort.dir === "asc" ? "ascending" : "descending") : "none"} className={cn("group/col flex min-w-0", align === "right" && "justify-end", align === "center" && "justify-center", className)}>
407 + <button type="button" onClick={() => onSort(k)} title={title} className={cn("flex min-w-0 items-center gap-1 rounded px-1 py-0.5 transition-colors hover:text-fg", on && "text-fg")}>
408 + <span className="truncate">{children}</span>
409 + {on ? sort.dir === "asc" ? <ArrowUp className="size-3 shrink-0" /> : <ArrowDown className="size-3 shrink-0" /> : <ArrowUpDown className="size-3 shrink-0 opacity-0 group-hover/col:opacity-100" />}
410 + </button>
411 + </div>
300 412 );
301 413 }
302 414
303 −function StatusBadge({ m }: { m: PolyModel }) {
304 − const sd = shutdownDate(m);
415 +function SelectDot({ selected, disabled, onClick, name }: { selected: boolean; disabled: boolean; onClick: () => void; name: string }) {
305 416 return (
306 − <span className="flex flex-wrap items-center gap-1">
307 − <Badge variant={STATUS_VARIANT[m.status]} className="capitalize">
308 − {m.status}
309 − </Badge>
310 − {sd ? (
311 − <Tooltip content={`Provider shutdown scheduled for ${formatDate(sd)}`}>
312 − <Badge variant="warning">
313 − <AlertTriangle /> {new Date(sd).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
314 − </Badge>
315 − </Tooltip>
316 − ) : null}
317 − </span>
417 + <button
418 + type="button"
419 + role="checkbox"
420 + aria-checked={selected}
421 + disabled={disabled}
422 + aria-label={`${selected ? "Remove" : "Add"} ${name} ${selected ? "from" : "to"} comparison`}
423 + onClick={(e) => {
424 + e.stopPropagation();
425 + onClick();
426 + }}
427 + className={cn("tap flex size-5 items-center justify-center rounded-full border transition-colors", selected ? "border-accent bg-accent text-accent-fg" : "border-border-strong text-transparent hover:border-fg", disabled && "opacity-30")}
428 + >
429 + <Check className="size-3" />
430 + </button>
318 431 );
319 432 }
320 433
321 −function FavButton({ fav, onFav, name }: { fav: boolean; onFav: () => void; name: string }) {
322 − return (
434 +const ModelRow = React.memo(function ModelRow({ m, ctx, fav, connected, selected, selectionFull, maxIn, maxOut, maxCtx, onFav, onOpen, onSelect }: { m: PolyModel; ctx: BadgeContext; fav: boolean; connected: boolean; selected: boolean; selectionFull: boolean; maxIn: number; maxOut: number; maxCtx: number; onFav: () => void; onOpen: () => void; onSelect: () => void }) {
435 + const tier = speedTier(m, ctx);
436 + const inP = m.pricing?.inputPerMillion;
437 + const outP = m.pricing?.outputPerMillion;
438 + const star = (
323 439 <button
324 440 type="button"
325 441 onClick={(e) => {
@@ -327,17 +443,16 @@ function FavButton({ fav, onFav, name }: { fav: boolean; onFav: () => void; name
327 443 onFav();
328 444 }}
329 445 aria-pressed={fav}
330 − aria-label={fav ? `Remove ${name} from favorites` : `Add ${name} to favorites`}
331 − className={cn("rounded-md p-1 transition-colors hover:bg-bg-muted", fav ? "text-warning" : "text-fg-subtle hover:text-fg")}
446 + aria-label={fav ? `Remove ${m.displayName} from favorites` : `Add ${m.displayName} to favorites`}
447 + className={cn("tap flex size-8 items-center justify-center rounded-md transition-colors hover:bg-bg-muted", fav ? "text-warning" : "text-fg-subtle hover:text-fg")}
332 448 >
333 − <Star className={cn("size-4", fav && "fill-current")} />
449 + <Star key={fav ? "on" : "off"} className={cn("size-4", fav && "fill-current animate-pop")} />
334 450 </button>
335 451 );
336 −}
337 −
338 −function ModelTableRow({ m, fav, connected, onFav, onOpen }: { m: PolyModel; fav: boolean; connected: boolean; onFav: () => void; onOpen: () => void }) {
339 452 return (
340 − <tr
453 + <div
454 + role="row"
455 + tabIndex={0}
341 456 onClick={onOpen}
342 457 onKeyDown={(e) => {
343 458 if (e.key === "Enter" || e.key === " ") {
@@ -345,218 +460,83 @@ function ModelTableRow({ m, fav, connected, onFav, onOpen }: { m: PolyModel; fav
345 460 onOpen();
346 461 }
347 462 }}
348 − tabIndex={0}
349 − role="button"
350 463 aria-label={`Open ${m.displayName}`}
351 − className={cn("cursor-pointer border-b border-border transition-colors last:border-b-0 hover:bg-bg-subtle/60 focus-visible:bg-bg-subtle/60 focus-visible:outline-none", !connected && "opacity-70")}
464 + className={cn("group cursor-pointer border-b border-hairline transition-colors hover:bg-bg-subtle/70 focus-visible:bg-bg-subtle focus-visible:outline-none", selected && "bg-accent-soft/40", !connected && "opacity-75")}
352 465 >
353 − <td className="px-3 py-2">
354 − <FavButton fav={fav} onFav={onFav} name={m.displayName} />
355 − </td>
356 − <td className="px-2 py-2">
357 − <div className="flex items-center gap-2.5">
358 − <ProviderIcon provider={m.provider} size={16} />
359 − <div className="min-w-0">
360 − <p className="truncate font-medium">{m.displayName}</p>
361 − <p className="truncate font-mono text-[11px] text-fg-subtle">{m.id}</p>
466 + {/* Mobile stacked */}
467 + <div className="flex items-start gap-2.5 py-2.5 md:hidden">
468 + <div className="pt-1">
469 + <SelectDot selected={selected} disabled={!selected && selectionFull} onClick={onSelect} name={m.displayName} />
470 + </div>
471 + <ProviderIcon provider={m.provider} size={18} className="mt-0.5 shrink-0" />
472 + <div className="min-w-0 flex-1">
473 + <div className="flex items-center gap-1.5">
474 + <span className="truncate text-[14px] font-medium">{m.displayName}</span>
475 + <StatusBadge model={m} connected={connected} ctx={ctx} />
476 + </div>
477 + <p className="truncate font-mono text-[11px] text-fg-subtle">{m.id}</p>
478 + <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11.5px] tabular-nums text-fg-muted">
479 + <span>{formatTokens(m.limits?.contextTokens)} ctx</span>
480 + <span>·</span>
481 + <span>
482 + {formatPrice(inP)} / {formatPrice(outP)}
483 + </span>
484 + <CapabilityGlyphs model={m} max={6} />
362 485 </div>
486 + <ModelBadges model={m} ctx={ctx} max={4} size="xs" className="mt-1" />
363 487 </div>
364 − </td>
365 − <td className="px-3 py-2 text-fg-muted">
366 − <span className="flex items-center gap-1.5">
367 − {providerName(m.provider)}
368 − {!connected ? <Badge>no key</Badge> : null}
369 − </span>
370 − </td>
371 − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatTokens(m.limits?.contextTokens)}</td>
372 − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatTokens(m.limits?.maxOutputTokens)}</td>
373 − <td className="px-3 py-2">
374 − <CapIcons m={m} />
375 − </td>
376 − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">
377 − {price(m.pricing?.inputPerMillion)} · {price(m.pricing?.outputPerMillion)}
378 − </td>
379 − <td className="px-3 py-2">
380 − <StatusBadge m={m} />
381 − </td>
382 − </tr>
383 − );
384 −}
488 + {star}
489 + </div>
385 490
386 −function ModelCard({ m, fav, connected, onFav, onOpen }: { m: PolyModel; fav: boolean; connected: boolean; onFav: () => void; onOpen: () => void }) {
387 − return (
388 − <li>
389 − <div role="button" tabIndex={0} onClick={onOpen} onKeyDown={(e) => (e.key === "Enter" ? onOpen() : null)} className={cn("rounded-xl border border-border bg-bg-elevated p-3 transition-colors active:bg-bg-subtle", !connected && "opacity-70")}>
390 − <div className="flex items-start gap-2.5">
391 − <ProviderIcon provider={m.provider} size={18} className="mt-0.5" />
392 − <div className="min-w-0 flex-1">
393 − <p className="truncate text-sm font-medium">{m.displayName}</p>
491 + {/* Desktop grid */}
492 + <div role="presentation" className="hidden h-14 items-center gap-x-2 md:grid md:grid-cols-[36px_minmax(0,1.6fr)_100px_88px_88px_76px_40px_40px_40px_100px_36px] lg:grid-cols-[36px_minmax(0,1.6fr)_100px_88px_88px_76px_76px_40px_40px_40px_84px_100px_36px]">
493 + <div className="flex justify-center">
494 + <SelectDot selected={selected} disabled={!selected && selectionFull} onClick={onSelect} name={m.displayName} />
495 + </div>
496 + <div className="flex min-w-0 items-center gap-2.5">
497 + <ProviderIcon provider={m.provider} size={16} />
498 + <div className="min-w-0">
499 + <div className="flex items-center gap-1.5">
500 + <p className="truncate text-[13.5px] font-medium">{m.displayName}</p>
501 + <ModelBadges model={m} ctx={ctx} max={3} size="xs" className="hidden xl:inline-flex" />
502 + </div>
394 503 <p className="truncate font-mono text-[11px] text-fg-subtle">{m.id}</p>
395 504 </div>
396 − <FavButton fav={fav} onFav={onFav} name={m.displayName} />
397 505 </div>
398 − <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-fg-muted">
399 − <span className="font-mono">{formatTokens(m.limits?.contextTokens)} ctx</span>
400 − <span className="font-mono">{formatTokens(m.limits?.maxOutputTokens)} out</span>
401 − <span className="font-mono">
402 − {price(m.pricing?.inputPerMillion)} · {price(m.pricing?.outputPerMillion)} /1M
403 − </span>
506 + <div className="truncate text-[13px] text-fg-muted">{providerName(m.provider)}</div>
507 + <PriceCell value={inP} max={maxIn} />
508 + <PriceCell value={outP} max={maxOut} />
509 + <div className="text-right">
510 + <div className="font-mono text-[12.5px] tabular-nums text-fg-muted">{formatTokens(m.limits?.contextTokens)}</div>
511 + <Meter value={m.limits?.contextTokens} max={maxCtx} className="ml-auto mt-1 w-14" tone="muted" />
404 512 </div>
405 − <div className="mt-2 flex items-center justify-between gap-2">
406 − <CapIcons m={m} max={5} />
407 − <StatusBadge m={m} />
513 + <div className="hidden text-right font-mono text-[12.5px] tabular-nums text-fg-muted lg:block">{formatTokens(m.limits?.maxOutputTokens)}</div>
514 + <Cap on={m.capabilities.reasoning} label="Reasoning" />
515 + <Cap on={m.capabilities.vision} label="Vision" />
516 + <Cap on={m.capabilities.tools} label="Tools" />
517 + <div className="hidden text-[12px] capitalize text-fg-muted lg:block">{tier}</div>
518 + <div>
519 + <StatusBadge model={m} connected={connected} ctx={ctx} showActive />
408 520 </div>
521 + <div className="flex justify-center">{star}</div>
409 522 </div>
410 − </li>
523 + </div>
411 524 );
412 −}
525 +});
413 526
414 −const PARAM_LABELS: Record<string, string> = {
415 − temperature: "Temperature",
416 − topP: "Top-p",
417 − topK: "Top-k",
418 − maxTokens: "Max tokens",
419 − reasoningEffort: "Reasoning effort",
420 − thinkingBudget: "Thinking budget",
421 − stop: "Stop sequences",
422 − seed: "Seed",
423 − frequencyPenalty: "Frequency penalty",
424 − presencePenalty: "Presence penalty",
425 − verbosity: "Verbosity",
426 −};
427 −
428 −const CAP_LABELS: Record<CapKey, string> = {
429 − text: "Text",
430 − vision: "Vision (images)",
431 − audioInput: "Audio input",
432 − audioOutput: "Audio output",
433 − imageGeneration: "Image generation",
434 − video: "Video",
435 − reasoning: "Reasoning",
436 − tools: "Tool calling",
437 − structuredOutput: "Structured output",
438 − streaming: "Streaming",
439 − files: "File attachments",
440 − webSearch: "Web search",
441 −};
442 −
443 −function ModelDetail({ model: m, onClose, connected, fav, onFav, onChat }: { model: PolyModel | null; onClose: () => void; connected: boolean; fav: boolean; onFav: () => void; onChat: () => void }) {
444 − const sd = m ? shutdownDate(m) : null;
445 − const aliases = m?.metadata?.aliases;
527 +function PriceCell({ value, max }: { value: number | undefined; max: number }) {
446 528 return (
447 − <Dialog open={Boolean(m)} onOpenChange={(v) => (!v ? onClose() : null)}>
448 − <DialogContent size="lg">
449 − {m ? (
450 − <>
451 − <DialogHeader>
452 − <div className="flex items-start gap-3 pr-8">
453 − <span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-subtle">
454 − <ProviderIcon provider={m.provider} size={20} />
455 − </span>
456 − <div className="min-w-0 flex-1">
457 − <div className="flex flex-wrap items-center gap-2">
458 − <DialogTitle>{m.displayName}</DialogTitle>
459 − <StatusBadge m={m} />
460 − </div>
461 − <DialogDescription className="mt-0.5 flex flex-wrap items-center gap-x-2 font-mono text-xs">
462 − <span>{m.key}</span>
463 − {m.family ? <span className="text-fg-subtle">· {m.family}</span> : null}
464 − </DialogDescription>
465 − </div>
466 − <FavButton fav={fav} onFav={onFav} name={m.displayName} />
467 − </div>
468 − </DialogHeader>
469 − <DialogBody className="space-y-5">
470 − {sd ? (
471 − <div className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning-soft px-3 py-2 text-xs text-warning">
472 − <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
473 − <p>
474 − {providerName(m.provider)} has scheduled this model for shutdown on <span className="font-medium">{formatDate(sd)}</span>. Move presets to a newer model before then.
475 − </p>
476 − </div>
477 − ) : null}
478 −
479 − <dl className="grid grid-cols-2 gap-2 sm:grid-cols-4">
480 − {[
481 − ["Context", formatTokens(m.limits?.contextTokens)],
482 − ["Max output", formatTokens(m.limits?.maxOutputTokens)],
483 − ["Input $/1M", price(m.pricing?.inputPerMillion)],
484 − ["Output $/1M", price(m.pricing?.outputPerMillion)],
485 − ].map(([k, v]) => (
486 − <div key={k} className="rounded-lg border border-border bg-bg-subtle/60 px-3 py-2">
487 − <dt className="text-[11px] text-fg-subtle">{k}</dt>
488 − <dd className="mt-0.5 font-mono text-sm tabular-nums">{v}</dd>
489 − </div>
490 − ))}
491 − </dl>
492 − {m.pricing?.cachedInputPerMillion !== undefined || m.pricing?.longContext ? (
493 − <p className="text-xs text-fg-muted">
494 − {m.pricing?.cachedInputPerMillion !== undefined ? <>Cached input {price(m.pricing.cachedInputPerMillion)} /1M. </> : null}
495 − {m.pricing?.longContext ? (
496 − <>
497 − Above {formatTokens(m.pricing.longContext.thresholdTokens)} tokens: {price(m.pricing.longContext.inputPerMillion)} in · {price(m.pricing.longContext.outputPerMillion)} out.{" "}
498 − </>
499 − ) : null}
500 − {m.pricing?.asOf ? <span className="text-fg-subtle">Prices as of {m.pricing.asOf}.</span> : null}
501 − </p>
502 − ) : null}
503 −
504 − <section>
505 − <h4 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Capabilities</h4>
506 − <ul className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
507 − {(Object.keys(CAP_LABELS) as CapKey[]).map((k) => {
508 − const on = m.capabilities[k];
509 − return (
510 − <li key={k} className={cn("flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs", on ? "border-border text-fg" : "border-dashed border-border text-fg-subtle line-through decoration-border-strong")}>
511 − <span className={cn("size-1.5 rounded-full", on ? "bg-success" : "bg-border-strong")} aria-hidden />
512 − {CAP_LABELS[k]}
513 − </li>
514 − );
515 − })}
516 − </ul>
517 − </section>
518 −
519 − <section>
520 − <h4 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Parameters</h4>
521 − <ul className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
522 − {Object.entries(PARAM_LABELS).map(([k, label]) => {
523 − const on = Boolean((m.parameters as Record<string, unknown>)[k]);
524 − let extra: string | null = null;
525 − if (k === "temperature" && m.parameters.temperatureRange) extra = `${m.parameters.temperatureRange.min}–${m.parameters.temperatureRange.max}`;
526 − if (k === "reasoningEffort" && m.parameters.reasoningEffortLevels?.length) extra = m.parameters.reasoningEffortLevels.join(" / ");
527 − if (k === "thinkingBudget" && m.parameters.thinkingBudgetRange) extra = `${formatTokens(m.parameters.thinkingBudgetRange.min)}–${formatTokens(m.parameters.thinkingBudgetRange.max)}`;
528 − return (
529 − <li key={k} className={cn("rounded-md border px-2.5 py-1.5 text-xs", on ? "border-border text-fg" : "border-dashed border-border text-fg-subtle")}>
530 − <span className={cn(!on && "line-through decoration-border-strong")}>{label}</span>
531 − {on && extra ? <span className="mt-0.5 block font-mono text-[10.5px] text-fg-muted">{extra}</span> : null}
532 − </li>
533 − );
534 − })}
535 − </ul>
536 − </section>
529 + <div className="text-right">
530 + <div className="font-mono text-[12.5px] tabular-nums text-fg-muted">{formatPrice(value)}</div>
531 + <Meter value={value ?? null} max={max} className="ml-auto mt-1 w-14" />
532 + </div>
533 + );
534 +}
537 535
538 − {Array.isArray(aliases) && aliases.length ? (
539 − <p className="text-xs text-fg-muted">
540 − Aliases: <span className="font-mono">{(aliases as string[]).join(", ")}</span>
541 − </p>
542 − ) : null}
543 − </DialogBody>
544 − <DialogFooter className="sm:justify-between">
545 − <span className="text-xs text-fg-subtle">{connected ? `${providerName(m.provider)} key connected` : `Connect a ${providerName(m.provider)} key to use this model`}</span>
546 − <div className="flex gap-2">
547 − {!connected ? (
548 − <Button variant="outline" asChild>
549 − <a href="/app/settings/providers">Connect provider</a>
550 − </Button>
551 − ) : null}
552 − <Button onClick={onChat} disabled={!connected || m.status === "deprecated"}>
553 − <MessageSquare /> Chat with this model
554 − </Button>
555 − </div>
556 − </DialogFooter>
557 − </>
558 − ) : null}
559 − </DialogContent>
560 − </Dialog>
536 +function Cap({ on, label }: { on: boolean; label: string }) {
537 + return (
538 + <div className="flex justify-center" aria-label={`${label}: ${on ? "yes" : "no"}`}>
539 + {on ? <Check className="size-4 text-success" /> : <span className="size-1 rounded-full bg-border-strong" />}
540 + </div>
561 541 );
562 542 }
added src/app/app/onboarding/onboarding-flow.tsx +593 −0
@@ -0,0 +1,593 @@
1 +"use client";
2 +import * as React from "react";
3 +import Link from "next/link";
4 +import { useRouter } from "next/navigation";
5 +import { ArrowLeft, ArrowRight, Check, CheckCircle2, ChevronRight, CircleDashed, ExternalLink, KeyRound, Loader2, MessageSquare, RefreshCw, Sparkles, Star, UserRound, XCircle } from "lucide-react";
6 +import { LogoMark } from "@/components/brand/logo";
7 +import { ProviderIcon } from "@/components/brand/provider-icon";
8 +import { Badge } from "@/components/ui/badge";
9 +import { Button } from "@/components/ui/button";
10 +import { toast } from "@/components/ui/toast";
11 +import { AddKeyDialog, useAddKeyDialog } from "@/components/providers/add-key-dialog";
12 +import { useApp } from "@/components/app/store";
13 +import { api } from "@/lib/client/api";
14 +import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks";
15 +import { errorMessage } from "@/lib/client/humanize";
16 +import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers";
17 +import { formatContext, formatPricePair } from "@/lib/marketing/public-models";
18 +import { cn, formatRelative } from "@/lib/utils";
19 +import type { PolyModel, ProviderId, PublicConnection } from "@/lib/client/types";
20 +
21 +/* ------------------------------------------------------------------------------------------------
22 + * Steps
23 + * ---------------------------------------------------------------------------------------------- */
24 +const STEPS = [
25 + { id: "account", title: "Create your account", short: "Account" },
26 + { id: "providers", title: "Choose your providers", short: "Providers" },
27 + { id: "key", title: "Add your first API key", short: "First key" },
28 + { id: "validate", title: "Validate the key", short: "Validate" },
29 + { id: "favorites", title: "Pick favorite models", short: "Favorites" },
30 + { id: "prompt", title: "Send your first prompt", short: "First prompt" },
31 +] as const;
32 +type StepId = (typeof STEPS)[number]["id"];
33 +
34 +const SUGGESTED_PROMPTS = [
35 + { title: "Explain a concept", text: "Explain how transformer attention works to a senior backend engineer, with a short worked example." },
36 + { title: "Review code", text: "Review the following code for bugs, edge cases and readability. Suggest concrete fixes.\n\n```\n\n```" },
37 + { title: "Draft an e-mail", text: "Draft a concise, friendly email to a client explaining a two-day delay and proposing a new timeline." },
38 + { title: "Compare models", text: "In three sentences each, what are you best at and what should I not use you for?" },
39 +];
40 +
41 +const LS_DONE_PREFIX = "polyllm:onboarding-done:";
42 +
43 +type ValidationResult = { ok: boolean; modelsAvailable?: number; error?: string; latencyMs?: number; at: number };
44 +
45 +/* ------------------------------------------------------------------------------------------------
46 + * Flow
47 + * ---------------------------------------------------------------------------------------------- */
48 +export function OnboardingFlow() {
49 + const router = useRouter();
50 + const isMobile = useIsMobile();
51 + const { user, connections, models, favorites, toggleFavorite, updatePreferences, refreshConnections, connectedProviders, loadingModels, setSelectedModelKey } = useApp();
52 +
53 + const [step, setStep] = React.useState(0);
54 + const [chosen, setChosen] = React.useState<Set<ProviderId>>(() => new Set());
55 + const [results, setResults] = React.useState<Partial<Record<ProviderId, ValidationResult>>>({});
56 + const [validating, setValidating] = React.useState<ProviderId | null>(null);
57 + const [finishing, setFinishing] = React.useState(false);
58 + const { ref: carouselRef, index: carouselIndex, scrollTo: carouselScrollTo } = useSnapCarousel<HTMLDivElement>(STEPS.length);
59 +
60 + const byProvider = React.useMemo(() => new Map(connections.map((c) => [c.provider, c])), [connections]);
61 + const connectedList = React.useMemo(() => connections.filter((c) => c.status !== "invalid").map((c) => c.provider), [connections]);
62 + /** Providers shown on the key step: the ones chosen, plus any already connected. */
63 + const keyProviders = React.useMemo(() => {
64 + const set = new Set<ProviderId>([...chosen, ...connections.map((c) => c.provider)]);
65 + return PROVIDER_ORDER.filter((p) => set.has(p));
66 + }, [chosen, connections]);
67 + const usableModels = React.useMemo(() => models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated"), [models, connectedProviders]);
68 + const favoriteModels = React.useMemo(() => usableModels.filter((m) => favorites.has(m.key)), [usableModels, favorites]);
69 +
70 + // Keep the mobile carousel and the step index in sync (swipe → index, buttons → scroll).
71 + const go = React.useCallback(
72 + (i: number) => {
73 + const next = Math.max(0, Math.min(STEPS.length - 1, i));
74 + setStep(next);
75 + if (isMobile) carouselScrollTo(next);
76 + },
77 + [isMobile, carouselScrollTo],
78 + );
79 + React.useEffect(() => {
80 + if (!isMobile) return;
81 + // eslint-disable-next-line react-hooks/set-state-in-effect
82 + setStep(carouselIndex);
83 + }, [carouselIndex, isMobile]);
84 +
85 + // Provider whose key dialog is open, so the dialog result can be recorded as a validation.
86 + const pendingProvider = React.useRef<ProviderId | null>(null);
87 + const keyDialog = useAddKeyDialog((res) => {
88 + const p = pendingProvider.current;
89 + if (p) setResults((r) => ({ ...r, [p]: { ok: res.ok, modelsAvailable: res.modelsAvailable, error: res.error, at: Date.now() } }));
90 + });
91 + const openKey = (p: ProviderId, replacing: boolean) => {
92 + pendingProvider.current = p;
93 + keyDialog.open(p, replacing);
94 + };
95 +
96 + const validate = async (p: ProviderId) => {
97 + setValidating(p);
98 + try {
99 + const res = await api<{ ok: boolean; error?: string; modelsAvailable?: number; latencyMs?: number }>("/api/providers", { method: "POST", json: { action: "validate", provider: p } });
100 + setResults((r) => ({ ...r, [p]: { ...res, at: Date.now() } }));
101 + await refreshConnections();
102 + if (res.ok) toast.success(`${PROVIDERS[p].name} key is valid`, res.modelsAvailable ? `${res.modelsAvailable} models available` : undefined);
103 + else toast.error(`${PROVIDERS[p].name} key rejected`, res.error);
104 + } catch (e) {
105 + toast.error("Validation failed", errorMessage(e));
106 + } finally {
107 + setValidating(null);
108 + }
109 + };
110 +
111 + const finish = async (destination: string, note?: string) => {
112 + setFinishing(true);
113 + try {
114 + await updatePreferences({ onboardingCompleted: true });
115 + try {
116 + window.localStorage.setItem(LS_DONE_PREFIX + user.id, "1");
117 + } catch {
118 + /* ignore */
119 + }
120 + if (favoriteModels[0]) setSelectedModelKey(favoriteModels[0].key);
121 + if (note) toast.info(note);
122 + router.replace(destination);
123 + router.refresh();
124 + } catch (e) {
125 + toast.error("Could not save", errorMessage(e));
126 + setFinishing(false);
127 + }
128 + };
129 +
130 + const openPrompt = (text?: string) => {
131 + const params = new URLSearchParams();
132 + if (favoriteModels[0]) params.set("model", favoriteModels[0].key);
133 + // TODO(integration: chat) — ChatView should prefill the composer draft from `?q=`.
134 + if (text) params.set("q", text);
135 + const qs = params.toString();
136 + void finish(`/app/chat${qs ? `?${qs}` : ""}`);
137 + };
138 +
139 + const canContinue: Record<StepId, boolean> = {
140 + account: true,
141 + providers: chosen.size > 0 || connectedList.length > 0,
142 + key: connectedList.length > 0,
143 + validate: connectedList.length > 0,
144 + favorites: true,
145 + prompt: true,
146 + };
147 + const current = STEPS[step];
148 + const completed = (i: number) => {
149 + const id = STEPS[i].id;
150 + if (id === "account") return true;
151 + if (id === "providers") return connectedList.length > 0 || (i < step && chosen.size > 0);
152 + if (id === "key" || id === "validate") return connectedList.length > 0;
153 + if (id === "favorites") return favoriteModels.length > 0;
154 + return false;
155 + };
156 +
157 + const panels: Record<StepId, React.ReactNode> = {
158 + account: <StepAccount name={user.name} email={user.email} />,
159 + providers: <StepProviders chosen={chosen} connected={byProvider} onToggle={(p) => setChosen((s) => (s.has(p) ? new Set([...s].filter((x) => x !== p)) : new Set([...s, p])))} />,
160 + key: <StepKey providers={keyProviders} connected={byProvider} onAdd={openKey} onPickMore={() => go(1)} />,
161 + validate: <StepValidate providers={keyProviders} connected={byProvider} results={results} validating={validating} onValidate={validate} onAdd={(p) => openKey(p, true)} />,
162 + favorites: <StepFavorites models={usableModels} favorites={favorites} loading={loadingModels} onToggle={toggleFavorite} />,
163 + prompt: <StepPrompt model={favoriteModels[0] ?? usableModels[0]} onPick={openPrompt} />,
164 + };
165 +
166 + const footer = (
167 + <div className="flex items-center gap-2">
168 + <Button variant="ghost" size="lg" className="h-11 px-3" onClick={() => go(step - 1)} disabled={step === 0 || finishing} aria-label="Previous step">
169 + <ArrowLeft />
170 + <span className="hidden sm:inline">Back</span>
171 + </Button>
172 + <div className="flex-1" />
173 + {step < STEPS.length - 1 ? (
174 + <>
175 + {!canContinue[current.id] || current.id === "favorites" ? (
176 + <Button variant="ghost" size="lg" className="h-11" onClick={() => go(step + 1)} disabled={finishing}>
177 + Skip
178 + </Button>
179 + ) : null}
180 + <Button size="lg" className="h-11 min-w-[9rem]" onClick={() => go(step + 1)} disabled={!canContinue[current.id] || finishing}>
181 + Continue
182 + <ArrowRight />
183 + </Button>
184 + </>
185 + ) : (
186 + <Button size="lg" className="h-11 min-w-[9rem]" loading={finishing} onClick={() => openPrompt()}>
187 + Open the chat
188 + <ArrowRight />
189 + </Button>
190 + )}
191 + </div>
192 + );
193 +
194 + return (
195 + <div className="flex h-full min-h-0 flex-1 flex-col">
196 + {/* Header */}
197 + <header className="flex h-12 shrink-0 items-center gap-3 px-4 pt-safe hairline-b sm:h-14 sm:px-6">
198 + <LogoMark size={22} />
199 + <span className="text-[15px] font-semibold tracking-tight">Set up PolyLLM</span>
200 + <span className="ml-1 font-mono text-[11px] tabular-nums text-fg-subtle">
201 + {step + 1} / {STEPS.length}
202 + </span>
203 + <div className="flex-1" />
204 + <Button variant="ghost" size="sm" className="h-9" loading={finishing} onClick={() => finish("/app/chat", "You can finish setup anytime in Settings → Providers.")}>
205 + Skip setup
206 + </Button>
207 + </header>
208 +
209 + {/* Mobile: progress bar + swipeable panels */}
210 + {isMobile ? (
211 + <>
212 + <div className="flex gap-1 px-4 pt-3" aria-hidden>
213 + {STEPS.map((s, i) => (
214 + <span key={s.id} className={cn("h-1 flex-1 rounded-full transition-colors", i <= step ? "bg-accent" : "bg-bg-muted")} />
215 + ))}
216 + </div>
217 + <div ref={carouselRef} className="snap-row min-h-0 flex-1" data-no-edge-swipe aria-roledescription="carousel">
218 + {STEPS.map((s, i) => (
219 + <section key={s.id} className="h-full overflow-y-auto px-4 pb-6 pt-4 scrollbar-thin" aria-label={`Step ${i + 1}: ${s.title}`} aria-hidden={i !== step}>
220 + <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-fg-subtle">Step {i + 1}</p>
221 + <h1 className="mt-1 text-balance text-2xl font-semibold tracking-tight">{s.title}</h1>
222 + <div className="mt-5">{panels[s.id]}</div>
223 + </section>
224 + ))}
225 + </div>
226 + <div className="shrink-0 border-t border-hairline bg-bg px-4 pt-3 pb-[max(16px,var(--sab))]">{footer}</div>
227 + </>
228 + ) : (
229 + /* Desktop: centered two-column layout */
230 + <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
231 + <div className="mx-auto grid w-full max-w-5xl gap-10 px-6 py-10 lg:grid-cols-[16rem_1fr] lg:px-8 lg:py-14">
232 + <ol className="lg:sticky lg:top-6 lg:self-start" aria-label="Setup steps">
233 + {STEPS.map((s, i) => {
234 + const done = completed(i) && i !== step;
235 + const active = i === step;
236 + return (
237 + <li key={s.id}>
238 + <button type="button" onClick={() => go(i)} className={cn("flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-[14px] transition-colors hover:bg-bg-subtle", active ? "font-semibold text-fg" : "text-fg-muted")} aria-current={active ? "step" : undefined}>
239 + <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold tabular-nums", done ? "bg-success-soft text-success" : active ? "bg-fg text-bg" : "bg-bg-muted text-fg-subtle")}>{done ? <Check className="size-3.5" /> : i + 1}</span>
240 + {s.short}
241 + </button>
242 + </li>
243 + );
244 + })}
245 + </ol>
246 + <div className="min-w-0 max-w-2xl">
247 + <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-fg-subtle">Step {step + 1} of {STEPS.length}</p>
248 + <h1 className="mt-1.5 text-balance text-3xl font-semibold tracking-tight">{current.title}</h1>
249 + <div className="mt-7">{panels[current.id]}</div>
250 + <div className="mt-10 border-t border-hairline pt-5">{footer}</div>
251 + </div>
252 + </div>
253 + </main>
254 + )}
255 +
256 + <AddKeyDialog {...keyDialog.props} />
257 + </div>
258 + );
259 +}
260 +
261 +/* ------------------------------------------------------------------------------------------------
262 + * Step panels
263 + * ---------------------------------------------------------------------------------------------- */
264 +function StepAccount({ name, email }: { name: string; email: string }) {
265 + return (
266 + <div className="space-y-4">
267 + <div className="flex items-center gap-3.5 rounded-2xl bg-success-soft/70 p-4">
268 + <span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-success text-white">
269 + <Check className="size-5" strokeWidth={2.5} />
270 + </span>
271 + <div className="min-w-0">
272 + <p className="text-[15px] font-semibold">Account created and e-mail verified</p>
273 + <p className="truncate text-[13.5px] text-fg-muted">
274 + {name} · {email}
275 + </p>
276 + </div>
277 + </div>
278 + <p className="text-[14.5px] leading-6 text-fg-muted">Five short steps remain: pick the providers you use, paste one API key, check it works, star a few models and send a first prompt. Every step can be skipped and revisited from Settings.</p>
279 + <ul className="grid gap-2 sm:grid-cols-3">
280 + {[
281 + { icon: <KeyRound />, t: "Your keys, encrypted", d: "AES-256-GCM, decrypted only for the request." },
282 + { icon: <Sparkles />, t: "Every model", d: "Nine providers plus your own endpoints." },
283 + { icon: <MessageSquare />, t: "Free to use", d: "You pay providers directly, at list price." },
284 + ].map((f) => (
285 + <li key={f.t} className="panel flex gap-3 p-3.5">
286 + <span className="text-accent [&_svg]:size-4.5">{f.icon}</span>
287 + <div>
288 + <p className="text-[13.5px] font-medium">{f.t}</p>
289 + <p className="mt-0.5 text-[12.5px] leading-5 text-fg-muted">{f.d}</p>
290 + </div>
291 + </li>
292 + ))}
293 + </ul>
294 + </div>
295 + );
296 +}
297 +
298 +function StepProviders({ chosen, connected, onToggle }: { chosen: Set<ProviderId>; connected: Map<ProviderId, PublicConnection>; onToggle: (p: ProviderId) => void }) {
299 + return (
300 + <div className="space-y-4">
301 + <p className="text-[14.5px] leading-6 text-fg-muted">Which providers do you have keys for? Pick as many as you like — you can add more later in Settings → Providers.</p>
302 + <ul className="grid grid-cols-2 gap-2 sm:grid-cols-3">
303 + {PROVIDER_ORDER.map((p) => {
304 + const meta = PROVIDERS[p];
305 + const isConnected = connected.has(p) && connected.get(p)!.status !== "invalid";
306 + const on = chosen.has(p) || isConnected;
307 + return (
308 + <li key={p}>
309 + <button
310 + type="button"
311 + aria-pressed={on}
312 + onClick={() => (isConnected ? null : onToggle(p))}
313 + className={cn(
314 + "flex min-h-[92px] w-full flex-col items-start gap-2 rounded-2xl p-3.5 text-left transition-[background-color,box-shadow] active:scale-[0.99]",
315 + on ? "bg-accent-soft/70 ring-1 ring-accent/40" : "bg-bg-subtle hover:bg-bg-muted",
316 + )}
317 + >
318 + <span className="flex w-full items-center justify-between">
319 + <span className="flex size-8 items-center justify-center rounded-lg bg-bg-elevated shadow-xs">
320 + <ProviderIcon provider={p} size={17} />
321 + </span>
322 + {isConnected ? (
323 + <Badge variant="success">
324 + <Check /> Connected
325 + </Badge>
326 + ) : on ? (
327 + <span className="flex size-5 items-center justify-center rounded-full bg-accent text-accent-fg">
328 + <Check className="size-3" strokeWidth={3} />
329 + </span>
330 + ) : (
331 + <span className="size-5 rounded-full border border-border-strong" aria-hidden />
332 + )}
333 + </span>
334 + <span className="text-[13.5px] font-semibold leading-5">{meta.name}</span>
335 + <span className="line-clamp-2 text-[11.5px] leading-4 text-fg-muted">{meta.description}</span>
336 + </button>
337 + </li>
338 + );
339 + })}
340 + </ul>
341 + <p className="text-[12.5px] text-fg-subtle">No key yet? Any of the “Get a key” links on the next step opens the provider console in a new tab.</p>
342 + </div>
343 + );
344 +}
345 +
346 +function StepKey({ providers, connected, onAdd, onPickMore }: { providers: ProviderId[]; connected: Map<ProviderId, PublicConnection>; onAdd: (p: ProviderId, replacing: boolean) => void; onPickMore: () => void }) {
347 + if (!providers.length) {
348 + return (
349 + <div className="panel p-5 text-center">
350 + <p className="text-[14.5px] font-medium">No provider selected</p>
351 + <p className="mt-1 text-[13.5px] text-fg-muted">Go back and pick at least one provider, or skip this step.</p>
352 + <Button variant="outline" className="mt-4" onClick={onPickMore}>
353 + Choose providers
354 + </Button>
355 + </div>
356 + );
357 + }
358 + return (
359 + <div className="space-y-4">
360 + <p className="text-[14.5px] leading-6 text-fg-muted">Paste one API key to get started. It is sent once over TLS, validated against the provider, then encrypted with AES-256-GCM — only a hint like sk-••••9A2K is shown afterwards.</p>
361 + <ul className="divide-y divide-hairline rounded-2xl bg-bg-subtle">
362 + {providers.map((p) => {
363 + const meta = PROVIDERS[p];
364 + const c = connected.get(p);
365 + const ok = c && c.status !== "invalid";
366 + return (
367 + <li key={p} className="flex flex-col gap-3 p-3.5 sm:flex-row sm:items-center">
368 + <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-elevated shadow-xs">
369 + <ProviderIcon provider={p} size={18} />
370 + </span>
371 + <div className="min-w-0 flex-1">
372 + <div className="flex flex-wrap items-center gap-2">
373 + <p className="text-[14px] font-semibold">{meta.name}</p>
374 + {ok ? (
375 + <Badge variant="success">
376 + <Check /> {c.keyHint}
377 + </Badge>
378 + ) : c ? (
379 + <Badge variant="danger">
380 + <XCircle /> Key rejected
381 + </Badge>
382 + ) : null}
383 + </div>
384 + <p className="mt-0.5 text-[12.5px] text-fg-muted">{meta.keyHelp}</p>
385 + </div>
386 + <div className="flex items-center gap-2 sm:shrink-0">
387 + <a href={meta.keyDocsUrl} target="_blank" rel="noreferrer noopener" className="tap inline-flex h-10 items-center gap-1 rounded-md px-2 text-[13px] text-fg-muted hover:text-accent sm:h-9">
388 + Get a key <ExternalLink className="size-3.5" />
389 + </a>
390 + <Button size="md" variant={ok ? "outline" : "accent"} className="h-10 flex-1 sm:h-9 sm:flex-none" onClick={() => onAdd(p, Boolean(c))}>
391 + <KeyRound />
392 + {ok ? "Replace" : c ? "Fix key" : "Add key"}
393 + </Button>
394 + </div>
395 + </li>
396 + );
397 + })}
398 + </ul>
399 + <button type="button" onClick={onPickMore} className="inline-flex items-center gap-1 text-[13px] font-medium text-accent underline-offset-4 hover:underline">
400 + Add another provider <ChevronRight className="size-3.5" />
401 + </button>
402 + </div>
403 + );
404 +}
405 +
406 +function StepValidate({ providers, connected, results, validating, onValidate, onAdd }: { providers: ProviderId[]; connected: Map<ProviderId, PublicConnection>; results: Partial<Record<ProviderId, ValidationResult>>; validating: ProviderId | null; onValidate: (p: ProviderId) => void; onAdd: (p: ProviderId) => void }) {
407 + const withKeys = providers.filter((p) => connected.has(p));
408 + if (!withKeys.length) {
409 + return (
410 + <div className="panel p-5 text-center">
411 + <p className="text-[14.5px] font-medium">Nothing to validate yet</p>
412 + <p className="mt-1 text-[13.5px] text-fg-muted">Add an API key on the previous step, or skip ahead — you can test keys anytime in Settings → Providers.</p>
413 + </div>
414 + );
415 + }
416 + return (
417 + <div className="space-y-4">
418 + <p className="text-[14.5px] leading-6 text-fg-muted">Each key was checked against its provider when you saved it. Here is the result; run the test again if you rotated the key or changed its permissions.</p>
419 + <ul className="divide-y divide-hairline rounded-2xl bg-bg-subtle">
420 + {withKeys.map((p) => {
421 + const c = connected.get(p)!;
422 + const r = results[p];
423 + const status = r ? (r.ok ? "valid" : "invalid") : c.status === "valid" ? "valid" : c.status === "invalid" ? "invalid" : "unverified";
424 + const modelsAvailable = r?.modelsAvailable ?? c.modelsAvailable;
425 + const err = r && !r.ok ? r.error : c.lastValidationError;
426 + return (
427 + <li key={p} className="flex flex-col gap-3 p-3.5 sm:flex-row sm:items-center">
428 + <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-[18px]", status === "valid" ? "bg-success-soft text-success" : status === "invalid" ? "bg-danger-soft text-danger" : "bg-warning-soft text-warning")}>
429 + {validating === p ? <Loader2 className="animate-spin" /> : status === "valid" ? <CheckCircle2 /> : status === "invalid" ? <XCircle /> : <CircleDashed />}
430 + </span>
431 + <div className="min-w-0 flex-1">
432 + <div className="flex flex-wrap items-center gap-2">
433 + <ProviderIcon provider={p} size={14} />
434 + <p className="text-[14px] font-semibold">{PROVIDERS[p].name}</p>
435 + <span className="font-mono text-[11.5px] text-fg-subtle">{c.keyHint}</span>
436 + </div>
437 + <p className={cn("mt-0.5 text-[12.5px]", status === "invalid" ? "text-danger" : "text-fg-muted")}>
438 + {status === "valid" ? (
439 + <>
440 + Valid{modelsAvailable ? ` · ${modelsAvailable} models available` : ""}
441 + {r?.latencyMs ? ` · ${Math.round(r.latencyMs)} ms` : ""}
442 + {c.lastValidatedAt ? ` · checked ${formatRelative(r ? new Date(r.at) : c.lastValidatedAt)}` : ""}
443 + </>
444 + ) : status === "invalid" ? (
445 + err ?? "The provider rejected this key."
446 + ) : (
447 + "Not validated yet."
448 + )}
449 + </p>
450 + </div>
451 + <div className="flex items-center gap-2 sm:shrink-0">
452 + {status === "invalid" ? (
453 + <Button variant="outline" className="h-10 flex-1 sm:h-9 sm:flex-none" onClick={() => onAdd(p)}>
454 + <KeyRound /> Replace key
455 + </Button>
456 + ) : null}
457 + <Button variant={status === "valid" ? "outline" : "accent"} className="h-10 flex-1 sm:h-9 sm:flex-none" loading={validating === p} onClick={() => onValidate(p)}>
458 + <RefreshCw /> Test connection
459 + </Button>
460 + </div>
461 + </li>
462 + );
463 + })}
464 + </ul>
465 + </div>
466 + );
467 +}
468 +
469 +function StepFavorites({ models, favorites, loading, onToggle }: { models: PolyModel[]; favorites: Set<string>; loading: boolean; onToggle: (key: string) => Promise<void> }) {
470 + const [busy, setBusy] = React.useState<string | null>(null);
471 + const [query, setQuery] = React.useState("");
472 + const grouped = React.useMemo(() => {
473 + const q = query.trim().toLowerCase();
474 + const map = new Map<ProviderId, PolyModel[]>();
475 + for (const m of models) {
476 + if (q && !`${m.displayName} ${m.key}`.toLowerCase().includes(q)) continue;
477 + const list = map.get(m.provider) ?? [];
478 + if (list.length < (q ? 50 : 6)) list.push(m);
479 + map.set(m.provider, list);
480 + }
481 + return PROVIDER_ORDER.filter((p) => map.has(p)).map((p) => [p, map.get(p)!] as const);
482 + }, [models, query]);
483 +
484 + const toggle = async (key: string) => {
485 + setBusy(key);
486 + try {
487 + await onToggle(key);
488 + } catch (e) {
489 + toast.error("Could not update favorites", errorMessage(e));
490 + } finally {
491 + setBusy(null);
492 + }
493 + };
494 +
495 + if (loading) {
496 + return (
497 + <div className="space-y-2" aria-busy>
498 + {Array.from({ length: 5 }).map((_, i) => (
499 + <div key={i} className="h-14 rounded-xl shimmer" />
500 + ))}
501 + </div>
502 + );
503 + }
504 + if (!models.length) {
505 + return (
506 + <div className="panel p-5 text-center">
507 + <p className="text-[14.5px] font-medium">No models yet</p>
508 + <p className="mt-1 text-[13.5px] text-fg-muted">Connect a provider and its models appear here. You can star favorites later from the model picker (⌘/).</p>
509 + </div>
510 + );
511 + }
512 + return (
513 + <div className="space-y-4">
514 + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
515 + <p className="text-[14.5px] leading-6 text-fg-muted">
516 + Starred models appear first in the picker. <span className="text-fg tabular-nums">{favorites.size}</span> starred so far.
517 + </p>
518 + <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter models…" aria-label="Filter models" className="h-10 rounded-lg border border-border bg-bg-elevated px-3 text-[15px] shadow-xs placeholder:text-fg-subtle focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25 sm:h-9 sm:w-56 sm:text-sm" />
519 + </div>
520 + <div className="space-y-4">
521 + {grouped.map(([p, list]) => (
522 + <section key={p}>
523 + <h2 className="mb-1.5 flex items-center gap-1.5 px-1 text-[12px] font-semibold uppercase tracking-[0.08em] text-fg-subtle">
524 + <ProviderIcon provider={p} size={12} /> {PROVIDERS[p].shortName}
525 + </h2>
526 + <ul className="divide-y divide-hairline rounded-2xl bg-bg-subtle">
527 + {list.map((m) => {
528 + const fav = favorites.has(m.key);
529 + const price = formatPricePair(m.pricing?.inputPerMillion, m.pricing?.outputPerMillion);
530 + return (
531 + <li key={m.key}>
532 + <button type="button" onClick={() => toggle(m.key)} disabled={busy === m.key} aria-pressed={fav} className="flex min-h-[52px] w-full items-center gap-3 px-3.5 py-2 text-left transition-colors hover:bg-bg-muted/60 active:bg-bg-muted disabled:opacity-60">
533 + <div className="min-w-0 flex-1">
534 + <p className="truncate text-[14px] font-medium">{m.displayName}</p>
535 + <p className="truncate font-mono text-[11px] text-fg-subtle">
536 + {formatContext(m.limits?.contextTokens)} ctx{price ? ` · ${price} / M` : ""}
537 + {m.capabilities.reasoning ? " · reasoning" : ""}
538 + {m.capabilities.vision ? " · vision" : ""}
539 + </p>
540 + </div>
541 + {busy === m.key ? <Loader2 className="size-4 animate-spin text-fg-subtle" /> : <Star className={cn("size-[18px] shrink-0 transition-colors", fav ? "fill-warning text-warning" : "text-border-strong")} aria-hidden />}
542 + </button>
543 + </li>
544 + );
545 + })}
546 + </ul>
547 + </section>
548 + ))}
549 + </div>
550 + </div>
551 + );
552 +}
553 +
554 +function StepPrompt({ model, onPick }: { model?: PolyModel; onPick: (text: string) => void }) {
555 + return (
556 + <div className="space-y-4">
557 + <p className="text-[14.5px] leading-6 text-fg-muted">
558 + {model ? (
559 + <>
560 + You are set. Your first prompt will go to <span className="font-medium text-fg">{model.displayName}</span> — switch models anytime with the pill above the composer or ⌘/.
561 + </>
562 + ) : (
563 + "You are set. Connect a provider later to start chatting; the workspace, Arena and catalog are ready now."
564 + )}
565 + </p>
566 + <ul className="grid gap-2 sm:grid-cols-2">
567 + {SUGGESTED_PROMPTS.map((s) => (
568 + <li key={s.title}>
569 + <button type="button" onClick={() => onPick(s.text)} className="flex min-h-[64px] w-full items-start gap-3 rounded-2xl bg-bg-subtle p-3.5 text-left transition-colors hover:bg-bg-muted active:scale-[0.99]">
570 + <span className="mt-0.5 text-accent">
571 + <MessageSquare className="size-4" />
572 + </span>
573 + <span className="min-w-0">
574 + <span className="block text-[14px] font-medium">{s.title}</span>
575 + <span className="mt-0.5 line-clamp-2 block text-[12.5px] leading-5 text-fg-muted">{s.text.split("\n")[0]}</span>
576 + </span>
577 + </button>
578 + </li>
579 + ))}
580 + </ul>
581 + <p className="text-[12.5px] text-fg-subtle">
582 + Or open an empty chat with the button below. Prefer comparing first?{" "}
583 + <Link href="/app/arena" className="text-accent underline-offset-4 hover:underline">
584 + Go to the Arena
585 + </Link>
586 + .
587 + </p>
588 + <div className="flex items-center gap-2 text-[12.5px] text-fg-subtle">
589 + <UserRound className="size-3.5" /> Everything here can be changed later in Settings.
590 + </div>
591 + </div>
592 + );
593 +}
added src/app/app/onboarding/page.tsx +17 −0
@@ -0,0 +1,17 @@
1 +import { Suspense } from "react";
2 +import { OnboardingFlow } from "./onboarding-flow";
3 +
4 +export const metadata = { title: "Get started" };
5 +
6 +/**
7 + * /app/onboarding — six-step first-run setup (account → providers → first key → validate →
8 + * favorite models → first prompt). Skippable at every step; completion is stored via
9 + * `PATCH /api/preferences { onboardingCompleted: true }`.
10 + */
11 +export default function OnboardingPage() {
12 + return (
13 + <Suspense>
14 + <OnboardingFlow />
15 + </Suspense>
16 + );
17 +}
modified src/app/app/presets/page.tsx +42 −61
@@ -6,12 +6,13 @@ import { PageHeader, Card, EmptyState, Skeleton } from "@/components/ui/misc";
6 6 import { Button } from "@/components/ui/button";
7 7 import { Input, Textarea, Field } from "@/components/ui/input";
8 8 import { Badge } from "@/components/ui/badge";
9 −import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter } from "@/components/ui/dialog";
9 +import { ResponsiveDialog } from "@/components/ui/sheet";
10 10 import { toast } from "@/components/ui/toast";
11 11 import { ProviderIcon } from "@/components/brand/provider-icon";
12 12 import { ConfirmDialog } from "@/components/common/confirm-dialog";
13 13 import { ModelSelect } from "@/components/presets/model-select";
14 14 import { ParametersForm, parameterChips, type Params } from "@/components/presets/parameters-form";
15 +import { pruneSettingsForModel } from "@/lib/models/params";
15 16 import { useApp } from "@/components/app/store";
16 17 import { api, useApi } from "@/lib/client/api";
17 18 import { errorMessage } from "@/lib/client/humanize";
@@ -116,28 +117,7 @@ const TEMPLATES: Template[] = [
116 117 ];
117 118
118 119 /** Drop parameters the chosen model does not support so the server never receives unusable settings. */
119 −function pruneForModel(p: Params, model: PolyModel | undefined): Params {
120 − if (!model) return p;
121 − const s = model.parameters;
122 − const c = model.capabilities;
123 − const out: Params = { ...p };
124 − if (!s.temperature) delete out.temperature;
125 − if (!s.topP) delete out.topP;
126 − if (!s.topK) delete out.topK;
127 − if (!s.maxTokens) delete out.maxTokens;
128 − if (!s.reasoningEffort) delete out.reasoningEffort;
129 − if (!s.thinkingBudget) delete out.thinkingBudget;
130 − if (!s.verbosity) delete out.verbosity;
131 − if (!s.frequencyPenalty) delete out.frequencyPenalty;
132 − if (!s.presencePenalty) delete out.presencePenalty;
133 − if (!s.seed) delete out.seed;
134 − if (!s.stop) delete out.stop;
135 − if (!c.webSearch) delete out.webSearch;
136 − if (!c.tools) delete out.tools;
137 − if (!c.structuredOutput) delete out.responseFormat;
138 − if (out.reasoningEffort && s.reasoningEffortLevels?.length && !s.reasoningEffortLevels.includes(out.reasoningEffort)) delete out.reasoningEffort;
139 − return out;
140 −}
120 +const pruneForModel = (p: Params, model: PolyModel | undefined): Params => pruneSettingsForModel(p, model);
141 121
142 122 export default function PresetsPage() {
143 123 return (
@@ -341,44 +321,45 @@ function PresetsInner() {
341 321 ) : null}
342 322 </div>
343 323
344 − <Dialog open={editor.open} onOpenChange={(v) => setEditor((s) => ({ ...s, open: v }))}>
345 − <DialogContent size="lg">
346 − <form onSubmit={save} className="contents">
347 − <DialogHeader>
348 − <DialogTitle>{editor.id ? "Edit preset" : "New preset"}</DialogTitle>
349 − <DialogDescription>Only controls supported by the selected model are shown{draftModel ? ` — ${PROVIDERS[draftModel.provider].shortName} ${draftModel.displayName}` : ""}.</DialogDescription>
350 − </DialogHeader>
351 − <DialogBody className="space-y-4">
352 − <div className="grid grid-cols-[64px_1fr] gap-3">
353 − <Field label="Icon" htmlFor="preset-icon">
354 − <Input id="preset-icon" value={editor.draft.icon} onChange={(e) => setDraft({ icon: e.target.value.slice(0, 4) })} placeholder="✨" className="text-center text-lg" maxLength={4} />
355 − </Field>
356 − <Field label="Name" htmlFor="preset-name">
357 − <Input id="preset-name" value={editor.draft.name} onChange={(e) => setDraft({ name: e.target.value })} placeholder="Careful coder" maxLength={80} required autoFocus />
358 − </Field>
359 − </div>
360 − <Field label="Description" htmlFor="preset-desc">
361 − <Input id="preset-desc" value={editor.draft.description} onChange={(e) => setDraft({ description: e.target.value })} placeholder="When to reach for this preset" maxLength={400} />
362 − </Field>
363 − <Field label="Model" hint={draftModel ? [draftModel.limits?.contextTokens ? `${(draftModel.limits.contextTokens / 1000).toFixed(0)}K context` : null, draftModel.capabilities.reasoning ? "reasoning" : null, draftModel.capabilities.tools ? "tools" : null, draftModel.capabilities.vision ? "vision" : null, draftModel.capabilities.webSearch ? "web search" : null].filter(Boolean).join(" · ") : "Only connected providers are listed."}>
364 − <ModelSelect value={editor.draft.modelKey} onChange={(k) => setDraft({ modelKey: k, parameters: pruneForModel(editor.draft.parameters, k ? modelsByKey.get(k) : undefined) })} />
365 − </Field>
366 − <Field label="System prompt" htmlFor="preset-system" hint="Optional. Replaces your default system prompt when this preset is used.">
367 − <Textarea id="preset-system" value={editor.draft.systemPrompt} onChange={(e) => setDraft({ systemPrompt: e.target.value })} rows={5} maxLength={50_000} className="font-mono text-[13px] leading-5" />
368 − </Field>
369 − <ParametersForm value={editor.draft.parameters} onChange={(p) => setDraft({ parameters: p })} model={draftModel ?? null} />
370 − </DialogBody>
371 − <DialogFooter>
372 − <Button type="button" variant="ghost" onClick={() => setEditor((s) => ({ ...s, open: false }))}>
373 − Cancel
374 − </Button>
375 − <Button type="submit" loading={saving} disabled={!editor.draft.name.trim() || !editor.draft.modelKey}>
376 − {editor.id ? "Save changes" : "Create preset"}
377 − </Button>
378 − </DialogFooter>
379 − </form>
380 − </DialogContent>
381 − </Dialog>
324 + <ResponsiveDialog
325 + open={editor.open}
326 + onOpenChange={(v) => setEditor((s) => ({ ...s, open: v }))}
327 + size="lg"
328 + snap="full"
329 + title={editor.id ? "Edit preset" : "New preset"}
330 + description={`Only controls supported by the selected model are shown${draftModel ? ` — ${PROVIDERS[draftModel.provider].shortName} ${draftModel.displayName}` : ""}.`}
331 + footer={
332 + <div className="flex gap-2 sm:justify-end">
333 + <Button type="button" variant="ghost" className="h-10 flex-1 sm:h-9 sm:flex-none" onClick={() => setEditor((s) => ({ ...s, open: false }))}>
334 + Cancel
335 + </Button>
336 + <Button type="submit" form="preset-form" className="h-10 flex-1 sm:h-9 sm:flex-none" loading={saving} disabled={!editor.draft.name.trim() || !editor.draft.modelKey}>
337 + {editor.id ? "Save changes" : "Create preset"}
338 + </Button>
339 + </div>
340 + }
341 + >
342 + <form id="preset-form" onSubmit={save} className="space-y-4 pt-1">
343 + <div className="grid grid-cols-[64px_1fr] gap-3">
344 + <Field label="Icon" htmlFor="preset-icon">
345 + <Input id="preset-icon" value={editor.draft.icon} onChange={(e) => setDraft({ icon: e.target.value.slice(0, 4) })} placeholder="✨" className="text-center text-lg" maxLength={4} />
346 + </Field>
347 + <Field label="Name" htmlFor="preset-name">
348 + <Input id="preset-name" value={editor.draft.name} onChange={(e) => setDraft({ name: e.target.value })} placeholder="Careful coder" maxLength={80} required />
349 + </Field>
350 + </div>
351 + <Field label="Description" htmlFor="preset-desc">
352 + <Input id="preset-desc" value={editor.draft.description} onChange={(e) => setDraft({ description: e.target.value })} placeholder="When to reach for this preset" maxLength={400} />
353 + </Field>
354 + <Field label="Model" hint={draftModel ? [draftModel.limits?.contextTokens ? `${(draftModel.limits.contextTokens / 1000).toFixed(0)}K context` : null, draftModel.capabilities.reasoning ? "reasoning" : null, draftModel.capabilities.tools ? "tools" : null, draftModel.capabilities.vision ? "vision" : null, draftModel.capabilities.webSearch ? "web search" : null].filter(Boolean).join(" · ") : "Only connected providers are listed."}>
355 + <ModelSelect value={editor.draft.modelKey} onChange={(k) => setDraft({ modelKey: k, parameters: pruneForModel(editor.draft.parameters, k ? modelsByKey.get(k) : undefined) })} />
356 + </Field>
357 + <Field label="System prompt" htmlFor="preset-system" hint="Optional. Replaces your default system prompt when this preset is used.">
358 + <Textarea id="preset-system" value={editor.draft.systemPrompt} onChange={(e) => setDraft({ systemPrompt: e.target.value })} rows={5} maxLength={50_000} className="font-mono text-[13px] leading-5" />
359 + </Field>
360 + <ParametersForm value={editor.draft.parameters} onChange={(p) => setDraft({ parameters: p })} model={draftModel ?? null} />
361 + </form>
362 + </ResponsiveDialog>
382 363
383 364 <ConfirmDialog
384 365 open={deleting !== null}
added src/app/app/projects/[id]/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import { Suspense } from "react";
2 +import { ProjectDetail } from "@/components/projects/project-detail";
3 +
4 +export const metadata = { title: "Project" };
5 +
6 +export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
7 + const { id } = await params;
8 + return (
9 + <Suspense>
10 + <ProjectDetail id={id} />
11 + </Suspense>
12 + );
13 +}
added src/app/app/projects/page.tsx +12 −0
@@ -0,0 +1,12 @@
1 +import { Suspense } from "react";
2 +import { ProjectsView } from "@/components/projects/projects-view";
3 +
4 +export const metadata = { title: "Projects" };
5 +
6 +export default function ProjectsPage() {
7 + return (
8 + <Suspense>
9 + <ProjectsView />
10 + </Suspense>
11 + );
12 +}
modified src/app/app/prompts/page.tsx +10 −264
@@ -1,270 +1,16 @@
1 −"use client";
2 −import * as React from "react";
3 −import { useRouter, useSearchParams } from "next/navigation";
4 −import { Plus, WandSparkles, Pencil, Trash2, MessageSquare, Search } from "lucide-react";
5 −import { PageHeader, Card, EmptyState, Skeleton } from "@/components/ui/misc";
6 −import { Button } from "@/components/ui/button";
7 −import { Input, Textarea, Field } from "@/components/ui/input";
8 −import { Badge } from "@/components/ui/badge";
9 −import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter } from "@/components/ui/dialog";
10 −import { toast } from "@/components/ui/toast";
11 −import { ProviderIcon } from "@/components/brand/provider-icon";
12 −import { ConfirmDialog } from "@/components/common/confirm-dialog";
13 −import { ModelSelect } from "@/components/presets/model-select";
14 −import { ParametersForm, parameterChips, type Params } from "@/components/presets/parameters-form";
15 −import { useApp } from "@/components/app/store";
16 −import { api, useApi } from "@/lib/client/api";
17 −import { errorMessage } from "@/lib/client/humanize";
18 −import { formatRelative, truncate } from "@/lib/utils";
19 −import type { PromptPreset, ModelPreset } from "@/lib/client/types";
1 +import { Suspense } from "react";
2 +import { PromptLibrary } from "@/components/prompts/prompt-library";
20 3
21 −interface PresetsResponse {
22 − modelPresets: ModelPreset[];
23 − promptPresets: PromptPreset[];
24 −}
25 −
26 −interface Draft {
27 − name: string;
28 − icon: string;
29 − description: string;
30 − systemPrompt: string;
31 − defaultModelKey: string | null;
32 − parameters: Params;
33 −}
34 −
35 −const EMPTY: Draft = { name: "", icon: "", description: "", systemPrompt: "", defaultModelKey: null, parameters: {} };
36 −
37 −function toDraft(p: PromptPreset): Draft {
38 − return { name: p.name, icon: p.icon ?? "", description: p.description ?? "", systemPrompt: p.systemPrompt, defaultModelKey: p.defaultModelKey ?? null, parameters: (p.parameters ?? {}) as Params };
39 −}
4 +export const metadata = { title: "Prompts" };
40 5
6 +/**
7 + * Prompt library (system / user / template / structured prompts with {{variables}}, folders, tags, favourites).
8 + * Legacy `prompt_presets` are listed at the bottom with "Import to library"; `/app/chat?prompt=<id>` keeps working.
9 + */
41 10 export default function PromptsPage() {
42 11 return (
43 − <React.Suspense fallback={null}>
44 − <PromptsInner />
45 − </React.Suspense>
46 − );
47 −}
48 −
49 −function PromptsInner() {
50 − const router = useRouter();
51 − const params = useSearchParams();
52 − const { modelsByKey } = useApp();
53 − const q = useApi<PresetsResponse>("/api/presets");
54 − const [search, setSearch] = React.useState("");
55 − const [editor, setEditor] = React.useState<{ open: boolean; id: string | null; draft: Draft }>({ open: false, id: null, draft: EMPTY });
56 − const [deleting, setDeleting] = React.useState<PromptPreset | null>(null);
57 − const [saving, setSaving] = React.useState(false);
58 −
59 − const prompts = React.useMemo(() => q.data?.promptPresets ?? [], [q.data]);
60 −
61 − // ?edit=<id> opens the editor once data is loaded.
62 − const editParam = params.get("edit");
63 − React.useEffect(() => {
64 − if (!editParam || !q.data) return;
65 − const target = editParam === "new" ? null : prompts.find((p) => p.id === editParam);
66 − if (editParam !== "new" && !target) return;
67 − // eslint-disable-next-line react-hooks/set-state-in-effect
68 − setEditor({ open: true, id: target?.id ?? null, draft: target ? toDraft(target) : EMPTY });
69 − router.replace("/app/prompts");
70 − }, [editParam, q.data, prompts, router]);
71 −
72 − const filtered = React.useMemo(() => {
73 − const s = search.trim().toLowerCase();
74 − if (!s) return prompts;
75 − return prompts.filter((p) => [p.name, p.description ?? "", p.systemPrompt].some((x) => x.toLowerCase().includes(s)));
76 − }, [prompts, search]);
77 −
78 − const openNew = () => setEditor({ open: true, id: null, draft: EMPTY });
79 − const openEdit = (p: PromptPreset) => setEditor({ open: true, id: p.id, draft: toDraft(p) });
80 − const setDraft = (patch: Partial<Draft>) => setEditor((e) => ({ ...e, draft: { ...e.draft, ...patch } }));
81 −
82 − const save = async (e: React.FormEvent) => {
83 − e.preventDefault();
84 − const d = editor.draft;
85 − if (!d.name.trim() || !d.systemPrompt.trim()) return;
86 − setSaving(true);
87 − const body = {
88 − name: d.name.trim(),
89 − icon: d.icon.trim() || null,
90 − description: d.description.trim() || null,
91 − systemPrompt: d.systemPrompt,
92 − defaultModelKey: d.defaultModelKey,
93 − parameters: d.parameters,
94 − };
95 − try {
96 − if (editor.id) await api(`/api/presets?kind=prompt&id=${editor.id}`, { method: "PATCH", json: body });
97 − else await api("/api/presets?kind=prompt", { method: "POST", json: body });
98 − await q.mutate();
99 − toast.success(editor.id ? "Prompt updated" : "Prompt created");
100 − setEditor((s) => ({ ...s, open: false }));
101 − } catch (err) {
102 − toast.error("Could not save prompt", errorMessage(err));
103 − } finally {
104 − setSaving(false);
105 − }
106 − };
107 −
108 − const remove = async (p: PromptPreset) => {
109 − try {
110 − await api(`/api/presets?kind=prompt&id=${p.id}`, { method: "DELETE" });
111 − await q.mutate();
112 − toast.success("Prompt deleted");
113 − } catch (err) {
114 − toast.error("Could not delete", errorMessage(err));
115 − throw err;
116 − }
117 − };
118 −
119 − return (
120 − <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
121 − <div className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 lg:px-8">
122 − <PageHeader
123 − title="Prompts"
124 − description="Reusable system prompts. Pick one when starting a chat, or set a default model and parameters for it."
125 − actions={
126 − <Button onClick={openNew}>
127 − <Plus /> New prompt
128 − </Button>
129 − }
130 − />
131 −
132 − {prompts.length > 0 ? (
133 − <div className="relative mt-5 max-w-sm">
134 − <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />
135 − <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search prompts…" className="pl-8" aria-label="Search prompts" />
136 − </div>
137 − ) : null}
138 −
139 − <div className="mt-5">
140 − {q.isLoading ? (
141 − <div className="grid gap-3 sm:grid-cols-2">
142 − {Array.from({ length: 4 }).map((_, i) => (
143 − <Skeleton key={i} className="h-40" />
144 − ))}
145 − </div>
146 − ) : q.error ? (
147 − <EmptyState title="Could not load prompts" description={errorMessage(q.error)} />
148 − ) : prompts.length === 0 ? (
149 − <EmptyState
150 − icon={<WandSparkles />}
151 − title="No prompts yet"
152 − description="Save the system prompts you keep retyping — a code reviewer, a translator, a strict JSON extractor — and start chats with them in one click."
153 − action={
154 − <Button onClick={openNew}>
155 − <Plus /> Create your first prompt
156 − </Button>
157 − }
158 − />
159 − ) : filtered.length === 0 ? (
160 − <EmptyState title="No matches" description={`Nothing matches “${search}”.`} />
161 − ) : (
162 − <ul className="grid gap-3 sm:grid-cols-2">
163 − {filtered.map((p) => {
164 − const model = p.defaultModelKey ? modelsByKey.get(p.defaultModelKey) : null;
165 − const chips = parameterChips(p.parameters);
166 − return (
167 − <li key={p.id}>
168 − <Card className="group flex h-full flex-col p-4 transition-colors hover:border-border-strong">
169 − <div className="flex items-start gap-3">
170 − <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-subtle text-lg leading-none" aria-hidden>
171 − {p.icon || <WandSparkles className="size-4 text-fg-muted" />}
172 − </span>
173 − <div className="min-w-0 flex-1">
174 − <h3 className="truncate text-[15px] font-semibold leading-6">{p.name}</h3>
175 − {p.description ? <p className="mt-0.5 line-clamp-2 text-[13px] text-fg-muted">{p.description}</p> : null}
176 − </div>
177 − <div className="flex shrink-0 gap-0.5 opacity-70 transition-opacity group-hover:opacity-100">
178 − <Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label={`Edit ${p.name}`}>
179 − <Pencil />
180 − </Button>
181 − <Button variant="ghost" size="icon-sm" onClick={() => setDeleting(p)} aria-label={`Delete ${p.name}`} className="hover:text-danger">
182 − <Trash2 />
183 − </Button>
184 − </div>
185 − </div>
186 − <pre className="mt-3 max-h-24 flex-1 overflow-hidden whitespace-pre-wrap break-words rounded-md border border-border bg-bg-subtle/70 px-3 py-2 font-mono text-[12px] leading-5 text-fg-muted">{truncate(p.systemPrompt, 320)}</pre>
187 − <div className="mt-3 flex flex-wrap items-center gap-1.5">
188 − {model ? (
189 − <Badge variant="outline" className="gap-1.5">
190 − <ProviderIcon provider={model.provider} size={11} /> {model.displayName}
191 − </Badge>
192 − ) : p.defaultModelKey ? (
193 − <Badge variant="outline" className="font-mono">
194 − {p.defaultModelKey}
195 − </Badge>
196 − ) : (
197 − <Badge>Any model</Badge>
198 − )}
199 − {chips.slice(0, 3).map((c) => (
200 − <Badge key={c}>{c}</Badge>
201 − ))}
202 − {chips.length > 3 ? <Badge>+{chips.length - 3}</Badge> : null}
203 − </div>
204 − <div className="mt-3 flex items-center justify-between gap-2 border-t border-border pt-3">
205 − <span className="text-[11px] text-fg-subtle">Updated {formatRelative(p.updatedAt as unknown as string)}</span>
206 − <Button size="sm" variant="secondary" onClick={() => router.push(`/app/chat?prompt=${p.id}`)}>
207 − <MessageSquare /> Use in chat
208 − </Button>
209 − </div>
210 − </Card>
211 − </li>
212 − );
213 − })}
214 − </ul>
215 − )}
216 − </div>
217 − </div>
218 −
219 − <Dialog open={editor.open} onOpenChange={(v) => setEditor((s) => ({ ...s, open: v }))}>
220 − <DialogContent size="lg">
221 − <form onSubmit={save} className="contents">
222 − <DialogHeader>
223 − <DialogTitle>{editor.id ? "Edit prompt" : "New prompt"}</DialogTitle>
224 − <DialogDescription>A system prompt plus optional defaults. Everything except the name and prompt is optional.</DialogDescription>
225 − </DialogHeader>
226 − <DialogBody className="space-y-4">
227 − <div className="grid grid-cols-[64px_1fr] gap-3">
228 − <Field label="Icon" htmlFor="prompt-icon">
229 − <Input id="prompt-icon" value={editor.draft.icon} onChange={(e) => setDraft({ icon: e.target.value.slice(0, 4) })} placeholder="✨" className="text-center text-lg" maxLength={4} aria-describedby="prompt-icon-hint" />
230 − </Field>
231 − <Field label="Name" htmlFor="prompt-name">
232 − <Input id="prompt-name" value={editor.draft.name} onChange={(e) => setDraft({ name: e.target.value })} placeholder="Code reviewer" maxLength={80} required autoFocus />
233 − </Field>
234 − </div>
235 − <p id="prompt-icon-hint" className="-mt-2 text-[11px] text-fg-subtle">Paste any emoji as the icon.</p>
236 − <Field label="Description" htmlFor="prompt-desc">
237 − <Input id="prompt-desc" value={editor.draft.description} onChange={(e) => setDraft({ description: e.target.value })} placeholder="What this prompt is for" maxLength={400} />
238 − </Field>
239 − <Field label="System prompt" htmlFor="prompt-system" hint={`${editor.draft.systemPrompt.length.toLocaleString()} characters`}>
240 − <Textarea id="prompt-system" value={editor.draft.systemPrompt} onChange={(e) => setDraft({ systemPrompt: e.target.value })} rows={8} maxLength={50_000} required className="font-mono text-[13px] leading-5" placeholder="You are a senior engineer reviewing pull requests…" />
241 − </Field>
242 − <Field label="Default model" hint="Optional. Only connected providers are listed.">
243 − <ModelSelect value={editor.draft.defaultModelKey} onChange={(k) => setDraft({ defaultModelKey: k })} allowNone noneLabel="Use the current chat model" />
244 − </Field>
245 − <ParametersForm compact value={editor.draft.parameters} onChange={(p) => setDraft({ parameters: p })} model={editor.draft.defaultModelKey ? modelsByKey.get(editor.draft.defaultModelKey) : null} />
246 − </DialogBody>
247 − <DialogFooter>
248 − <Button type="button" variant="ghost" onClick={() => setEditor((s) => ({ ...s, open: false }))}>
249 − Cancel
250 − </Button>
251 − <Button type="submit" loading={saving} disabled={!editor.draft.name.trim() || !editor.draft.systemPrompt.trim()}>
252 − {editor.id ? "Save changes" : "Create prompt"}
253 − </Button>
254 − </DialogFooter>
255 − </form>
256 − </DialogContent>
257 − </Dialog>
258 −
259 − <ConfirmDialog
260 − open={deleting !== null}
261 − onOpenChange={(v) => (!v ? setDeleting(null) : null)}
262 − destructive
263 − title={`Delete “${deleting?.name ?? ""}”?`}
264 − description="Conversations that used this prompt are not affected."
265 − confirmLabel="Delete"
266 − onConfirm={() => (deleting ? remove(deleting) : Promise.resolve())}
267 − />
268 − </main>
12 + <Suspense>
13 + <PromptLibrary />
14 + </Suspense>
269 15 );
270 16 }
modified src/app/app/settings/account/page.tsx +42 −9
@@ -8,6 +8,7 @@ import { Input, Field } from "@/components/ui/input";
8 8 import { Badge } from "@/components/ui/badge";
9 9 import { toast } from "@/components/ui/toast";
10 10 import { ConfirmDialog } from "@/components/common/confirm-dialog";
11 +import { SettingsSection } from "@/components/settings/section";
11 12 import { useApp } from "@/components/app/store";
12 13 import { authClient } from "@/lib/auth-client";
13 14 import { useApi } from "@/lib/client/api";
@@ -75,15 +76,15 @@ export default function AccountSettingsPage() {
75 76 };
76 77
77 78 return (
78 − <div className="space-y-5">
79 + <SettingsSection title="Account" description="Profile, sign-in email and the security log of your account." className="space-y-5">
79 80 <Card>
80 81 <CardHeader title="Profile" description="How you appear in PolyLLM." />
81 82 <CardBody>
82 83 <form onSubmit={saveName} className="flex flex-col gap-3 sm:flex-row sm:items-end">
83 84 <Field label="Display name" htmlFor="account-name" className="flex-1">
84 − <Input id="account-name" value={name} onChange={(e) => setName(e.target.value)} maxLength={80} autoComplete="name" />
85 + <Input id="account-name" value={name} onChange={(e) => setName(e.target.value)} maxLength={80} autoComplete="name" className="h-11 text-[16px] sm:h-9 sm:text-sm" />
85 86 </Field>
86 − <Button type="submit" loading={savingName} disabled={!name.trim() || name.trim() === user.name}>
87 + <Button type="submit" loading={savingName} disabled={!name.trim() || name.trim() === user.name} className="h-11 sm:h-9">
87 88 Save
88 89 </Button>
89 90 </form>
@@ -119,9 +120,9 @@ export default function AccountSettingsPage() {
119 120 </div>
120 121 <form onSubmit={requestEmailChange} className="flex flex-col gap-3 sm:flex-row sm:items-end">
121 122 <Field label="New email" htmlFor="account-new-email" className="flex-1" hint="We send an approval link to your current address first; the new address is then verified before it becomes active.">
122 − <Input id="account-new-email" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="you@example.com" autoComplete="email" inputMode="email" />
123 + <Input id="account-new-email" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="you@example.com" autoComplete="email" inputMode="email" className="h-11 text-[16px] sm:h-9 sm:text-sm" />
123 124 </Field>
124 − <Button type="submit" variant="outline" loading={emailBusy} disabled={!newEmail.trim()}>
125 + <Button type="submit" variant="outline" loading={emailBusy} disabled={!newEmail.trim()} className="h-11 sm:h-9">
125 126 Request change
126 127 </Button>
127 128 </form>
@@ -159,7 +160,38 @@ export default function AccountSettingsPage() {
159 160 <EmptyState title="No activity yet" description="Sign-ins, key changes and security events will appear here." />
160 161 </div>
161 162 ) : (
162 − <div className="overflow-x-auto">
163 + <>
164 + {/* Phone: stacked rows */}
165 + <ul className="divide-y divide-hairline border-t border-hairline md:hidden">
166 + {audit.data.audit.map((row) => {
167 + const ua = describeUserAgent(row.userAgent);
168 + const tone = actionTone(row.action);
169 + return (
170 + <li key={row.id} className="flex min-h-[56px] items-start gap-3 px-5 py-3">
171 + <span className={`mt-2 size-1.5 shrink-0 rounded-full ${tone === "danger" ? "bg-danger" : tone === "warning" ? "bg-warning" : tone === "success" ? "bg-success" : tone === "info" ? "bg-info" : "bg-border-strong"}`} aria-hidden />
172 + <div className="min-w-0 flex-1">
173 + <p className="flex flex-wrap items-center gap-2 text-[14px] font-medium leading-5">
174 + {humanizeAction(row.action)}
175 + {row.meta && typeof row.meta.provider === "string" ? <Badge className="capitalize">{String(row.meta.provider)}</Badge> : null}
176 + </p>
177 + <p className="mt-0.5 flex flex-wrap gap-x-1.5 text-[12px] text-fg-muted">
178 + <span>{formatDateTime(row.createdAt, { year: "numeric" })}</span>
179 + <span aria-hidden>·</span>
180 + <span>{ua.label}</span>
181 + {row.ipAddress ? (
182 + <>
183 + <span aria-hidden>·</span>
184 + <span className="font-mono">{row.ipAddress}</span>
185 + </>
186 + ) : null}
187 + </p>
188 + </div>
189 + </li>
190 + );
191 + })}
192 + </ul>
193 + {/* Desktop: table */}
194 + <div className="hidden overflow-x-auto md:block">
163 195 <table className="w-full min-w-[560px] text-sm">
164 196 <thead>
165 197 <tr className="border-y border-border bg-bg-subtle/60 text-left text-[11px] font-medium uppercase tracking-wide text-fg-subtle">
@@ -194,7 +226,8 @@ export default function AccountSettingsPage() {
194 226 })}
195 227 </tbody>
196 228 </table>
197 − </div>
229 + </div>
230 + </>
198 231 )}
199 232 </CardBody>
200 233 </Card>
@@ -209,7 +242,7 @@ export default function AccountSettingsPage() {
209 242 Permanently removes your conversations, presets, usage history and encrypted provider keys. We first send a confirmation email to <span className="font-medium text-fg">{user.email}</span>; nothing is deleted until you open that link.
210 243 </p>
211 244 </div>
212 − <Button variant="danger-soft" onClick={() => setDeleteOpen(true)}>
245 + <Button variant="danger-soft" onClick={() => setDeleteOpen(true)} className="h-11 sm:h-9">
213 246 <Trash2 /> Delete account
214 247 </Button>
215 248 </div>
@@ -226,6 +259,6 @@ export default function AccountSettingsPage() {
226 259 typeToConfirm="DELETE"
227 260 onConfirm={deleteAccount}
228 261 />
229 − </div>
262 + </SettingsSection>
230 263 );
231 264 }
modified src/app/app/settings/appearance/page.tsx +10 −6
@@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
9 9 import { Kbd } from "@/components/ui/misc";
10 10 import { toast } from "@/components/ui/toast";
11 11 import { useApp } from "@/components/app/store";
12 +import { SettingsSection } from "@/components/settings/section";
12 13 import { errorMessage } from "@/lib/client/humanize";
13 14 import { cn } from "@/lib/utils";
14 15 import type { UserPreferences } from "@/lib/client/types";
@@ -68,7 +69,7 @@ export default function AppearanceSettingsPage() {
68 69 };
69 70
70 71 return (
71 − <div className="space-y-5">
72 + <SettingsSection title="Appearance" description="Theme, chat behavior and what appears alongside responses." className="space-y-5">
72 73 <Card>
73 74 <CardHeader title="Theme" description={mounted && resolvedTheme ? `Currently rendering ${resolvedTheme}.` : "Choose how PolyLLM looks."} />
74 75 <CardBody>
@@ -83,7 +84,7 @@ export default function AppearanceSettingsPage() {
83 84 aria-checked={active}
84 85 onClick={() => chooseTheme(t.value)}
85 86 className={cn(
86 − "group relative flex flex-col items-start gap-2 rounded-xl border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40",
87 + "group relative flex min-h-[44px] flex-col items-start gap-2 rounded-xl border p-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 sm:p-3",
87 88 active ? "border-accent bg-accent-soft/60" : "border-border bg-bg-subtle/60 hover:border-border-strong",
88 89 )}
89 90 >
@@ -137,21 +138,24 @@ export default function AppearanceSettingsPage() {
137 138 <p className="mt-2 text-xs text-fg-subtle">PolyLLM is currently available in English. Model responses follow the language you write in.</p>
138 139 </CardBody>
139 140 </Card>
140 − </div>
141 + </SettingsSection>
141 142 );
142 143 }
143 144
144 145 function ToggleRow({ label, description, checked, disabled, onChange }: { label: string; description: React.ReactNode; checked: boolean; disabled?: boolean; onChange: (v: boolean) => void }) {
145 146 const id = React.useId();
146 147 return (
147 − <div className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0">
148 + <div className="flex min-h-[52px] items-start justify-between gap-4 py-3 first:pt-0 last:pb-0">
148 149 <div className="min-w-0">
149 − <label htmlFor={id} className="text-sm font-medium">
150 + <label htmlFor={id} className="block text-sm font-medium leading-5">
150 151 {label}
151 152 </label>
152 153 <p className="mt-0.5 text-xs leading-5 text-fg-muted">{description}</p>
153 154 </div>
154 − <Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onChange} className="mt-0.5" />
155 + {/* 44 px hit area around the switch */}
156 + <span className="flex min-h-[44px] min-w-[44px] items-center justify-center">
157 + <Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onChange} />
158 + </span>
155 159 </div>
156 160 );
157 161 }
modified src/app/app/settings/data/page.tsx +16 −8
@@ -8,6 +8,7 @@ import { Textarea, Field } from "@/components/ui/input";
8 8 import { toast } from "@/components/ui/toast";
9 9 import { useApp } from "@/components/app/store";
10 10 import { ModelSelect } from "@/components/presets/model-select";
11 +import { SettingsSection } from "@/components/settings/section";
11 12 import { errorMessage } from "@/lib/client/humanize";
12 13
13 14 export default function DataSettingsPage() {
@@ -62,7 +63,7 @@ export default function DataSettingsPage() {
62 63 };
63 64
64 65 return (
65 − <div className="space-y-5">
66 + <SettingsSection title="Data" description="Defaults for new chats, a full export of your data and what PolyLLM keeps." className="space-y-5">
66 67 <Card>
67 68 <CardHeader title="Defaults for new chats" description="Applied when you start a conversation without a preset." />
68 69 <CardBody className="space-y-5">
@@ -75,10 +76,10 @@ export default function DataSettingsPage() {
75 76 <div className="flex items-center justify-between gap-3">
76 77 <span className="text-xs text-fg-subtle tabular-nums">{prompt.length.toLocaleString()} / 20,000</span>
77 78 <div className="flex gap-2">
78 − <Button variant="ghost" size="sm" disabled={!dirty} onClick={() => setPrompt(preferences.defaultSystemPrompt ?? "")}>
79 + <Button variant="ghost" size="sm" className="h-11 sm:h-8" disabled={!dirty} onClick={() => setPrompt(preferences.defaultSystemPrompt ?? "")}>
79 80 Reset
80 81 </Button>
81 − <Button size="sm" loading={savingPrompt} disabled={!dirty} onClick={savePrompt}>
82 + <Button size="sm" className="h-11 sm:h-8" loading={savingPrompt} disabled={!dirty} onClick={savePrompt}>
82 83 Save prompt
83 84 </Button>
84 85 </div>
@@ -97,9 +98,16 @@ export default function DataSettingsPage() {
97 98 </li>
98 99 ))}
99 100 </ul>
100 − <Button variant="outline" loading={exporting} onClick={exportAll}>
101 − <Download /> Download export (.json)
102 − </Button>
101 + <div className="flex flex-col gap-2 sm:flex-row">
102 + <Button variant="outline" loading={exporting} onClick={exportAll} className="h-11 sm:h-9">
103 + <Download /> Download export (.json)
104 + </Button>
105 + <Button variant="ghost" asChild className="h-11 sm:h-9">
106 + <a href="/api/usage/export.csv?range=all" download>
107 + <Download /> Usage records (.csv)
108 + </a>
109 + </Button>
110 + </div>
103 111 </CardBody>
104 112 </Card>
105 113
@@ -124,13 +132,13 @@ export default function DataSettingsPage() {
124 132 <p className="text-sm font-medium">Delete account</p>
125 133 <p className="mt-0.5 text-xs text-fg-muted">Removes everything above permanently, after an email confirmation.</p>
126 134 </div>
127 − <Button variant="danger-soft" asChild>
135 + <Button variant="danger-soft" asChild className="h-11 sm:h-9">
128 136 <Link href="/app/settings/account#danger">
129 137 <Trash2 /> Go to danger zone
130 138 </Link>
131 139 </Button>
132 140 </CardBody>
133 141 </Card>
134 − </div>
142 + </SettingsSection>
135 143 );
136 144 }
added src/app/app/settings/endpoints/page.tsx +144 −0
@@ -0,0 +1,144 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Plus, Plug, Info } from "lucide-react";
4 +import { Button } from "@/components/ui/button";
5 +import { EmptyState, Skeleton } from "@/components/ui/misc";
6 +import { toast } from "@/components/ui/toast";
7 +import { ConfirmDialog } from "@/components/common/confirm-dialog";
8 +import { SettingsSection } from "@/components/settings/section";
9 +import { EndpointRow } from "@/components/endpoints/endpoint-row";
10 +import { EndpointSheet } from "@/components/endpoints/endpoint-sheet";
11 +import { ENDPOINT_PRESETS } from "@/components/endpoints/presets";
12 +import { useApp } from "@/components/app/store";
13 +import { api, useApi } from "@/lib/client/api";
14 +import { errorMessage } from "@/lib/client/humanize";
15 +import { formatMs } from "@/lib/utils";
16 +import type { PublicEndpoint } from "@/lib/client/types";
17 +
18 +interface ListResponse {
19 + endpoints: PublicEndpoint[];
20 + allowPrivate: boolean;
21 +}
22 +
23 +export default function EndpointsSettingsPage() {
24 + const { refreshModels } = useApp();
25 + const q = useApi<ListResponse>("/api/endpoints");
26 + const [sheet, setSheet] = React.useState<{ open: boolean; endpoint: PublicEndpoint | null }>({ open: false, endpoint: null });
27 + const [testing, setTesting] = React.useState<string | null>(null);
28 + const [syncing, setSyncing] = React.useState<string | null>(null);
29 + const [removing, setRemoving] = React.useState<PublicEndpoint | null>(null);
30 +
31 + const endpoints = q.data?.endpoints ?? [];
32 + const allowPrivate = q.data?.allowPrivate ?? false;
33 +
34 + const replace = React.useCallback(
35 + (e: PublicEndpoint) => {
36 + void q.mutate((prev) => (prev ? { ...prev, endpoints: prev.endpoints.some((x) => x.id === e.id) ? prev.endpoints.map((x) => (x.id === e.id ? e : x)) : [e, ...prev.endpoints] } : prev), { revalidate: true });
37 + void refreshModels();
38 + },
39 + [q, refreshModels],
40 + );
41 +
42 + const test = async (e: PublicEndpoint) => {
43 + setTesting(e.id);
44 + try {
45 + const r = await api<{ ok: boolean; latencyMs: number; modelsAvailable: number; error?: string; endpoint: PublicEndpoint }>(`/api/endpoints/${e.id}/test`, { method: "POST" });
46 + replace(r.endpoint);
47 + if (r.ok) toast.success(`${e.name} is reachable`, `${r.modelsAvailable} models · ${formatMs(r.latencyMs)}`);
48 + else toast.error(`${e.name} is unreachable`, r.error);
49 + } catch (err) {
50 + toast.error("Test failed", errorMessage(err));
51 + } finally {
52 + setTesting(null);
53 + }
54 + };
55 +
56 + const sync = async (e: PublicEndpoint) => {
57 + setSyncing(e.id);
58 + try {
59 + const r = await api<{ ok: boolean; models: unknown[]; latencyMs: number; error?: string; endpoint: PublicEndpoint }>(`/api/endpoints/${e.id}/sync`, { method: "POST" });
60 + replace(r.endpoint);
61 + if (r.ok) toast.success("Model list refreshed", `${r.models.length} models · ${formatMs(r.latencyMs)}`);
62 + else toast.error("Could not refresh models", r.error);
63 + } catch (err) {
64 + toast.error("Sync failed", errorMessage(err));
65 + } finally {
66 + setSyncing(null);
67 + }
68 + };
69 +
70 + const remove = async (e: PublicEndpoint) => {
71 + try {
72 + await api(`/api/endpoints/${e.id}`, { method: "DELETE" });
73 + void q.mutate((prev) => (prev ? { ...prev, endpoints: prev.endpoints.filter((x) => x.id !== e.id) } : prev), { revalidate: true });
74 + void refreshModels();
75 + toast.success(`${e.name} removed`);
76 + } catch (err) {
77 + toast.error("Could not remove endpoint", errorMessage(err));
78 + throw err;
79 + }
80 + };
81 +
82 + const addButton = (
83 + <Button size="sm" onClick={() => setSheet({ open: true, endpoint: null })} className="h-10 md:h-8">
84 + <Plus /> Add endpoint
85 + </Button>
86 + );
87 +
88 + return (
89 + <SettingsSection title="Endpoints" description="Bring your own OpenAI-compatible servers. Models appear in the picker as provider “Custom”, keyed by endpoint." actions={endpoints.length ? addButton : undefined}>
90 + {q.isLoading ? (
91 + <div className="space-y-3">
92 + <Skeleton className="h-[140px] rounded-xl" />
93 + <Skeleton className="h-[140px] rounded-xl" />
94 + </div>
95 + ) : q.error ? (
96 + <EmptyState title="Could not load endpoints" description={errorMessage(q.error)} action={<Button size="sm" variant="outline" onClick={() => void q.mutate()}>Retry</Button>} />
97 + ) : endpoints.length === 0 ? (
98 + <EmptyState
99 + icon={<Plug />}
100 + title="No custom endpoints yet"
101 + description="Connect Ollama, LM Studio, vLLM, llama.cpp, MLX or any server exposing /v1/chat/completions. Discovery reads /v1/models; you can also declare models by hand."
102 + action={
103 + <div className="flex flex-col items-center gap-3">
104 + {addButton}
105 + <div className="flex flex-wrap justify-center gap-1.5">
106 + {ENDPOINT_PRESETS.filter((p) => p.id !== "other").map((p) => (
107 + <button key={p.id} type="button" onClick={() => setSheet({ open: true, endpoint: null })} className="tap h-8 rounded-full border border-border px-3 text-[12.5px] text-fg-muted hover:border-border-strong hover:text-fg">
108 + {p.name}
109 + </button>
110 + ))}
111 + </div>
112 + </div>
113 + }
114 + />
115 + ) : (
116 + <ul className="space-y-3">
117 + {endpoints.map((e) => (
118 + <EndpointRow key={e.id} endpoint={e} testing={testing === e.id} syncing={syncing === e.id} onTest={() => test(e)} onSync={() => sync(e)} onEdit={() => setSheet({ open: true, endpoint: e })} onRemove={() => setRemoving(e)} />
119 + ))}
120 + </ul>
121 + )}
122 +
123 + <div className="flex gap-2 rounded-lg bg-bg-subtle px-3 py-2.5 text-xs leading-5 text-fg-muted">
124 + <Info className="mt-0.5 size-3.5 shrink-0 text-info" />
125 + <p>
126 + <span className="font-medium text-fg">Where do requests come from?</span> The PolyLLM <em>server</em> calls your endpoint, not your browser. On www.polyllm.io that means <code className="font-mono">localhost</code> and LAN addresses cannot be reached
127 + {allowPrivate ? " — this deployment allows private addresses, so anything reachable from the server works." : ". Publish the server through a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) or self-host PolyLLM next to it with ALLOW_PRIVATE_ENDPOINTS=1."}{" "}
128 + Keys and header values are encrypted at rest; custom-endpoint usage is tracked but has no cost estimate unless you know the price.
129 + </p>
130 + </div>
131 +
132 + <EndpointSheet open={sheet.open} onOpenChange={(v) => setSheet((s) => ({ ...s, open: v }))} endpoint={sheet.endpoint} allowPrivate={allowPrivate} onSaved={(e) => replace(e)} />
133 + <ConfirmDialog
134 + open={removing !== null}
135 + onOpenChange={(v) => (!v ? setRemoving(null) : null)}
136 + destructive
137 + title={`Remove ${removing?.name ?? "endpoint"}?`}
138 + description="Its models disappear from the picker immediately. Conversations that used them are kept."
139 + confirmLabel="Remove endpoint"
140 + onConfirm={() => (removing ? remove(removing) : Promise.resolve())}
141 + />
142 + </SettingsSection>
143 + );
144 +}
modified src/app/app/settings/layout.tsx +88 −28
@@ -1,47 +1,83 @@
1 1 "use client";
2 2 import * as React from "react";
3 3 import Link from "next/link";
4 −import { usePathname } from "next/navigation";
5 −import { UserRound, ShieldCheck, KeyRound, Palette, Database, type LucideIcon } from "lucide-react";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
6 +import { Button } from "@/components/ui/button";
7 +import { useApp } from "@/components/app/store";
8 +import { SETTINGS_SECTIONS } from "@/components/settings/sections";
9 +import { useIsMobile, useMounted } from "@/lib/client/hooks";
6 10 import { cn } from "@/lib/utils";
7 11
8 −const TABS: { href: string; label: string; icon: LucideIcon; description: string }[] = [
9 − { href: "/app/settings/account", label: "Account", icon: UserRound, description: "Profile, email, audit log" },
10 − { href: "/app/settings/security", label: "Security", icon: ShieldCheck, description: "Password and sessions" },
11 − { href: "/app/settings/providers", label: "Providers", icon: KeyRound, description: "API keys and status" },
12 − { href: "/app/settings/appearance", label: "Appearance", icon: Palette, description: "Theme and chat behavior" },
13 − { href: "/app/settings/data", label: "Data", icon: Database, description: "Defaults, export, retention" },
14 −];
15 −
12 +/**
13 + * Settings shell.
14 + * Phone: `/app/settings` is a full-width list of sections; each section page gets a 48 px back bar.
15 + * Desktop: "Settings" header + side navigation, `/app/settings` redirects to Account.
16 + */
16 17 export default function SettingsLayout({ children }: { children: React.ReactNode }) {
17 18 const pathname = usePathname();
19 + const router = useRouter();
20 + const isMobile = useIsMobile();
21 + const mounted = useMounted();
22 + const { setSidebarOpen } = useApp();
23 + const isIndex = pathname === "/app/settings" || pathname === "/app/settings/";
24 + const current = SETTINGS_SECTIONS.find((t) => pathname === t.href || pathname.startsWith(t.href + "/"));
25 +
26 + React.useEffect(() => {
27 + if (mounted && isIndex && !isMobile) router.replace("/app/settings/account");
28 + }, [mounted, isIndex, isMobile, router]);
29 +
30 + if (!mounted) return <main className="min-h-0 flex-1" aria-busy="true" />;
31 +
32 + if (isMobile) {
33 + return (
34 + <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
35 + {isIndex ? (
36 + <>
37 + <div className="sticky top-0 z-20 flex h-12 items-center gap-1 border-b border-hairline bg-bg/90 px-2 backdrop-blur">
38 + <Button variant="ghost" size="icon-lg" aria-label="Open menu" onClick={() => setSidebarOpen(true)}>
39 + <Menu />
40 + </Button>
41 + <h1 className="flex-1 truncate text-[16px] font-semibold tracking-tight">Settings</h1>
42 + </div>
43 + <SectionList />
44 + </>
45 + ) : (
46 + <>
47 + <div className="sticky top-0 z-20 flex h-12 items-center gap-1 border-b border-hairline bg-bg/90 px-1 backdrop-blur">
48 + <Button variant="ghost" size="icon-lg" aria-label="Back to settings" asChild>
49 + <Link href="/app/settings">
50 + <ChevronLeft />
51 + </Link>
52 + </Button>
53 + <h1 className="flex-1 truncate text-[16px] font-semibold tracking-tight">{current?.label ?? "Settings"}</h1>
54 + </div>
55 + <div className="px-4 py-4">{children}</div>
56 + </>
57 + )}
58 + </main>
59 + );
60 + }
61 +
18 62 return (
19 63 <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
20 − <div className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 lg:px-8">
64 + <div className="mx-auto w-full max-w-5xl px-6 py-6 lg:px-8">
21 65 <header className="mb-5">
22 66 <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
23 − <p className="mt-1 text-sm text-fg-muted">Your account, keys and workspace preferences.</p>
67 + <p className="mt-1 text-sm text-fg-muted">Your account, keys, endpoints and workspace preferences.</p>
24 68 </header>
25 − <div className="flex flex-col gap-6 md:flex-row md:items-start">
26 − {/* Mobile: horizontal scroll tabs. Desktop: side nav. */}
27 − <nav aria-label="Settings sections" className="-mx-4 px-4 md:mx-0 md:w-52 md:shrink-0 md:px-0">
28 − <ul className="flex gap-1 overflow-x-auto pb-1 scrollbar-none md:flex-col md:overflow-visible md:pb-0">
29 − {TABS.map((t) => {
69 + <div className="flex items-start gap-8">
70 + <nav aria-label="Settings sections" className="w-52 shrink-0">
71 + <ul className="flex flex-col gap-0.5">
72 + {SETTINGS_SECTIONS.map((t) => {
30 73 const active = pathname === t.href || pathname.startsWith(t.href + "/");
31 74 return (
32 − <li key={t.href} className="shrink-0">
33 − <Link
34 − href={t.href}
35 − aria-current={active ? "page" : undefined}
36 − className={cn(
37 − "group flex items-center gap-2.5 rounded-md px-2.5 py-2 text-[13.5px] font-medium transition-colors md:w-full",
38 − active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg",
39 − )}
40 − >
75 + <li key={t.href}>
76 + <Link href={t.href} aria-current={active ? "page" : undefined} className={cn("group flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-[13.5px] font-medium transition-colors", active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}>
41 77 <t.icon className={cn("size-4 shrink-0", active ? "text-accent" : "text-fg-subtle group-hover:text-fg-muted")} />
42 78 <span className="flex flex-col leading-tight">
43 79 <span>{t.label}</span>
44 − <span className="hidden text-[11px] font-normal text-fg-subtle md:inline">{t.description}</span>
80 + <span className="text-[11px] font-normal text-fg-subtle">{t.description}</span>
45 81 </span>
46 82 </Link>
47 83 </li>
@@ -49,9 +85,33 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
49 85 })}
50 86 </ul>
51 87 </nav>
52 − <div className="min-w-0 flex-1">{children}</div>
88 + <div className="min-w-0 flex-1">{isIndex ? null : children}</div>
53 89 </div>
54 90 </div>
55 91 </main>
56 92 );
57 93 }
94 +
95 +/** Phone index: full-width tappable rows (≥ 56 px) grouped in one panel. */
96 +function SectionList() {
97 + return (
98 + <div className="px-4 py-4">
99 + <ul className="panel divide-y divide-hairline overflow-hidden">
100 + {SETTINGS_SECTIONS.map((t) => (
101 + <li key={t.href}>
102 + <Link href={t.href} className="flex min-h-[60px] items-center gap-3 px-4 py-3 active:bg-bg-muted/70">
103 + <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-elevated text-fg-muted shadow-xs">
104 + <t.icon className="size-[18px]" />
105 + </span>
106 + <span className="min-w-0 flex-1">
107 + <span className="block text-[15px] font-medium leading-5">{t.label}</span>
108 + <span className="block text-[12.5px] text-fg-muted">{t.description}</span>
109 + </span>
110 + <ChevronRight className="size-4 shrink-0 text-fg-subtle" />
111 + </Link>
112 + </li>
113 + ))}
114 + </ul>
115 + </div>
116 + );
117 +}
modified src/app/app/settings/page.tsx +5 −3
@@ -1,5 +1,7 @@
1 −import { redirect } from "next/navigation";
2 −
1 +/**
2 + * `/app/settings` — on phones the layout renders the full-width section list here;
3 + * on desktop the layout redirects to `/app/settings/account`.
4 + */
3 5 export default function SettingsIndex() {
4 − redirect("/app/settings/account");
6 + return null;
5 7 }
modified src/app/app/settings/providers/page.tsx +59 −126
@@ -1,42 +1,35 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { ExternalLink, KeyRound, RefreshCw, Trash2, CheckCircle2, XCircle, CircleDashed, CircleOff } from "lucide-react";
4 −import { Card } from "@/components/ui/misc";
5 −import { Button } from "@/components/ui/button";
6 −import { Badge } from "@/components/ui/badge";
3 +import Link from "next/link";
4 +import { ChevronRight, Plug, ShieldCheck } from "lucide-react";
7 5 import { toast } from "@/components/ui/toast";
8 −import { Tooltip } from "@/components/ui/tooltip";
9 −import { ProviderIcon } from "@/components/brand/provider-icon";
10 6 import { AddKeyDialog, useAddKeyDialog } from "@/components/providers/add-key-dialog";
7 +import { ProviderRow, type TestResult } from "@/components/providers/provider-row";
11 8 import { ConfirmDialog } from "@/components/common/confirm-dialog";
9 +import { SettingsSection } from "@/components/settings/section";
12 10 import { useApp } from "@/components/app/store";
13 −import { api } from "@/lib/client/api";
11 +import { api, useApi } from "@/lib/client/api";
14 12 import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers";
15 −import { formatRelative, formatMs } from "@/lib/utils";
13 +import { formatMs } from "@/lib/utils";
16 14 import { errorMessage } from "@/lib/client/humanize";
17 15 import type { ProviderId, PublicConnection } from "@/lib/client/types";
18 16
19 −type Status = "valid" | "invalid" | "unverified" | "missing";
20 −
21 −function statusOf(c: PublicConnection | undefined): Status {
22 − if (!c) return "missing";
23 − if (c.status === "valid") return "valid";
24 − if (c.status === "invalid") return "invalid";
25 − return "unverified";
17 +interface TestResponse {
18 + ok: boolean;
19 + latencyMs: number;
20 + modelsAvailable: number | null;
21 + error?: string;
22 + errorCode?: string;
23 + connections: PublicConnection[];
26 24 }
27 25
28 −const STATUS_UI: Record<Status, { label: string; variant: "success" | "danger" | "warning" | "default"; icon: React.ReactNode }> = {
29 − valid: { label: "Connected", variant: "success", icon: <CheckCircle2 /> },
30 − invalid: { label: "Key rejected", variant: "danger", icon: <XCircle /> },
31 − unverified: { label: "Unverified", variant: "warning", icon: <CircleDashed /> },
32 − missing: { label: "Missing key", variant: "default", icon: <CircleOff /> },
33 −};
34 −
35 26 export default function ProvidersSettingsPage() {
36 27 const { connections, refreshConnections, models } = useApp();
37 28 const keyDialog = useAddKeyDialog();
38 − const [validating, setValidating] = React.useState<ProviderId | null>(null);
29 + const [testing, setTesting] = React.useState<ProviderId | null>(null);
30 + const [tests, setTests] = React.useState<Partial<Record<ProviderId, TestResult>>>({});
39 31 const [removing, setRemoving] = React.useState<ProviderId | null>(null);
32 + const endpoints = useApi<{ endpoints: { id: string; status: string }[] }>("/api/endpoints");
40 33
41 34 const byProvider = React.useMemo(() => new Map(connections.map((c) => [c.provider, c])), [connections]);
42 35 const modelCounts = React.useMemo(() => {
@@ -45,17 +38,18 @@ export default function ProvidersSettingsPage() {
45 38 return m;
46 39 }, [models]);
47 40
48 − const validate = async (p: ProviderId) => {
49 − setValidating(p);
41 + const test = async (p: ProviderId) => {
42 + setTesting(p);
50 43 try {
51 − const res = await api<{ ok: boolean; error?: string; modelsAvailable?: number; latencyMs?: number }>("/api/providers", { method: "POST", json: { action: "validate", provider: p } });
44 + const res = await api<TestResponse>("/api/providers/test", { method: "POST", json: { provider: p } });
45 + setTests((t) => ({ ...t, [p]: { ok: res.ok, latencyMs: res.latencyMs, modelsAvailable: res.modelsAvailable, error: res.error, at: Date.now() } }));
52 46 await refreshConnections();
53 − if (res.ok) toast.success(`${PROVIDERS[p].name} key is valid`, `${res.modelsAvailable ?? "—"} models · ${formatMs(res.latencyMs)}`);
54 − else toast.error(`${PROVIDERS[p].name} key rejected`, res.error);
47 + if (res.ok) toast.success(`${PROVIDERS[p].name} is reachable`, `${res.modelsAvailable ?? "—"} models · ${formatMs(res.latencyMs)}`);
48 + else toast.error(`${PROVIDERS[p].name} rejected the key`, res.error);
55 49 } catch (e) {
56 − toast.error("Validation failed", errorMessage(e));
50 + toast.error("Test failed", errorMessage(e));
57 51 } finally {
58 − setValidating(null);
52 + setTesting(null);
59 53 }
60 54 };
61 55
@@ -63,6 +57,7 @@ export default function ProvidersSettingsPage() {
63 57 try {
64 58 await api(`/api/providers?provider=${p}`, { method: "DELETE" });
65 59 await refreshConnections();
60 + setTests((t) => ({ ...t, [p]: undefined }));
66 61 toast.success(`${PROVIDERS[p].name} key removed`);
67 62 } catch (e) {
68 63 toast.error("Could not remove key", errorMessage(e));
@@ -71,106 +66,44 @@ export default function ProvidersSettingsPage() {
71 66 };
72 67
73 68 const connectedCount = connections.filter((c) => c.status === "valid").length;
69 + const failedCount = connections.filter((c) => c.status === "invalid" || c.status === "error").length;
70 + const endpointCount = endpoints.data?.endpoints.length ?? 0;
71 + const validEndpoints = endpoints.data?.endpoints.filter((e) => e.status === "valid").length ?? 0;
74 72
75 − return (
76 − <div className="space-y-4">
77 − <div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
78 − <div>
79 − <h2 className="text-[15px] font-semibold tracking-tight">Providers</h2>
80 − <p className="text-[13px] text-fg-muted">
81 − {connectedCount} of {PROVIDER_ORDER.length} connected. Keys are validated live, encrypted at rest and never displayed again.
82 − </p>
83 − </div>
84 − </div>
73 + // Connected first, then failed, then the rest — stable within each group.
74 + const ordered = React.useMemo(() => {
75 + const rank = (p: ProviderId) => {
76 + const c = byProvider.get(p);
77 + if (!c) return 2;
78 + return c.status === "valid" ? 0 : 1;
79 + };
80 + return [...PROVIDER_ORDER].sort((a, b) => rank(a) - rank(b) || PROVIDER_ORDER.indexOf(a) - PROVIDER_ORDER.indexOf(b));
81 + }, [byProvider]);
85 82
83 + return (
84 + <SettingsSection title="Providers" description={`${connectedCount} of ${PROVIDER_ORDER.length} connected${failedCount ? ` · ${failedCount} need attention` : ""}. Keys are validated live, encrypted at rest and never displayed again.`}>
86 85 <ul className="space-y-3">
87 − {PROVIDER_ORDER.map((p) => {
88 − const meta = PROVIDERS[p];
89 − const conn = byProvider.get(p);
90 − const status = statusOf(conn);
91 − const ui = STATUS_UI[status];
92 − const models = conn?.modelsAvailable ?? modelCounts.get(p) ?? null;
93 − return (
94 − <li key={p}>
95 − <Card className="overflow-hidden">
96 − <div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:p-5">
97 − <span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-subtle">
98 − <ProviderIcon provider={p} size={20} />
99 − </span>
100 − <div className="min-w-0 flex-1">
101 − <div className="flex flex-wrap items-center gap-2">
102 − <h3 className="text-[15px] font-semibold leading-6">{meta.name}</h3>
103 − <Badge variant={ui.variant}>
104 − {ui.icon} {ui.label}
105 − </Badge>
106 − </div>
107 − <p className="mt-0.5 text-[13px] text-fg-muted">{meta.description}</p>
108 −
109 − {conn ? (
110 − <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4">
111 − <div>
112 − <dt className="text-fg-subtle">Key</dt>
113 − <dd className="mt-0.5 font-mono text-fg">{conn.keyHint}</dd>
114 − </div>
115 − <div>
116 − <dt className="text-fg-subtle">Last validated</dt>
117 − <dd className="mt-0.5 text-fg">{formatRelative(conn.lastValidatedAt)}</dd>
118 − </div>
119 − <div>
120 − <dt className="text-fg-subtle">Last successful request</dt>
121 − <dd className="mt-0.5 text-fg">{formatRelative(conn.lastSuccessAt)}</dd>
122 − </div>
123 − <div>
124 − <dt className="text-fg-subtle">Models available</dt>
125 − <dd className="mt-0.5 text-fg tabular-nums">{models ?? "—"}</dd>
126 − </div>
127 − {conn.lastErrorCode || conn.lastValidationError ? (
128 − <div className="col-span-2 sm:col-span-4">
129 − <dt className="text-fg-subtle">Last error</dt>
130 − <dd className="mt-0.5 flex flex-wrap items-center gap-2 text-danger">
131 − {conn.lastErrorCode ? <code className="rounded bg-danger-soft px-1.5 py-0.5 text-[11px]">{conn.lastErrorCode}</code> : null}
132 − {conn.lastValidationError ? <span className="text-fg-muted">{conn.lastValidationError}</span> : null}
133 − {conn.lastErrorAt ? <span className="text-fg-subtle">· {formatRelative(conn.lastErrorAt)}</span> : null}
134 − </dd>
135 − </div>
136 − ) : null}
137 − </dl>
138 − ) : (
139 − <p className="mt-3 text-xs text-fg-subtle">
140 − {meta.keyHelp}{" "}
141 − <a href={meta.keyDocsUrl} target="_blank" rel="noreferrer noopener" className="inline-flex items-center gap-0.5 text-accent hover:underline">
142 − Open console <ExternalLink className="size-3" />
143 − </a>
144 − </p>
145 − )}
146 − </div>
147 −
148 − <div className="flex shrink-0 flex-wrap gap-2 sm:flex-col sm:items-stretch">
149 − <Button size="sm" variant={conn ? "outline" : "primary"} onClick={() => keyDialog.open(p, Boolean(conn))}>
150 − <KeyRound /> {conn ? "Replace key" : "Add key"}
151 − </Button>
152 − {conn ? (
153 − <>
154 − <Tooltip content="Re-check the key against the provider and refresh its model list">
155 − <Button size="sm" variant="ghost" loading={validating === p} onClick={() => validate(p)}>
156 − <RefreshCw /> Validate now
157 − </Button>
158 − </Tooltip>
159 − <Button size="sm" variant="ghost" className="text-danger hover:bg-danger-soft hover:text-danger" onClick={() => setRemoving(p)}>
160 − <Trash2 /> Remove
161 − </Button>
162 − </>
163 − ) : null}
164 − </div>
165 − </div>
166 − </Card>
167 − </li>
168 − );
169 − })}
86 + {ordered.map((p) => (
87 + <ProviderRow key={p} provider={p} connection={byProvider.get(p)} modelCount={modelCounts.get(p) ?? null} testing={testing === p} lastTest={tests[p]} onAddKey={() => keyDialog.open(p, Boolean(byProvider.get(p)))} onTest={() => test(p)} onRemove={() => setRemoving(p)} />
88 + ))}
170 89 </ul>
171 90
172 − <p className="text-xs text-fg-subtle">
173 − Requests are sent from PolyLLM servers directly to each provider using your key. Usage is billed by the provider to your own account; PolyLLM shows estimated costs from public price lists.
91 + <Link href="/app/settings/endpoints" className="panel mt-4 flex min-h-[64px] items-center gap-3 p-4 transition-colors hover:bg-bg-muted/60 active:bg-bg-muted">
92 + <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-bg-elevated shadow-xs">
93 + <Plug className="size-5 text-fg-muted" />
94 + </span>
95 + <span className="min-w-0 flex-1">
96 + <span className="block text-[15px] font-semibold leading-6">Custom endpoints</span>
97 + <span className="block text-[13px] text-fg-muted">
98 + {endpointCount === 0 ? "Connect Ollama, LM Studio, vLLM, llama.cpp or any OpenAI-compatible server." : `${endpointCount} endpoint${endpointCount === 1 ? "" : "s"} · ${validEndpoints} reachable`}
99 + </span>
100 + </span>
101 + <ChevronRight className="size-4 shrink-0 text-fg-subtle" />
102 + </Link>
103 +
104 + <p className="mt-4 flex items-start gap-2 text-xs leading-5 text-fg-subtle">
105 + <ShieldCheck className="mt-0.5 size-3.5 shrink-0" />
106 + Requests go from PolyLLM servers directly to each provider with your key. Usage is billed by the provider to your own account; PolyLLM only shows estimated costs from public price lists.
174 107 </p>
175 108
176 109 <AddKeyDialog {...keyDialog.props} />
@@ -183,6 +116,6 @@ export default function ProvidersSettingsPage() {
183 116 confirmLabel="Remove key"
184 117 onConfirm={() => (removing ? remove(removing) : Promise.resolve())}
185 118 />
186 − </div>
119 + </SettingsSection>
187 120 );
188 121 }
modified src/app/app/settings/security/page.tsx +20 −10
@@ -10,6 +10,7 @@ import { Switch } from "@/components/ui/switch";
10 10 import { Badge } from "@/components/ui/badge";
11 11 import { toast } from "@/components/ui/toast";
12 12 import { ConfirmDialog } from "@/components/common/confirm-dialog";
13 +import { SettingsSection } from "@/components/settings/section";
13 14 import { authClient, useSession } from "@/lib/auth-client";
14 15 import { describeUserAgent, formatDateTime, formatRelativeSafe } from "@/lib/client/humanize";
15 16
@@ -98,31 +99,32 @@ export default function SecuritySettingsPage() {
98 99
99 100 const currentToken = session?.session?.token;
100 101
102 + const field = "h-11 text-[16px] sm:h-9 sm:text-sm";
101 103 return (
102 − <div className="space-y-5">
104 + <SettingsSection title="Security" description="Password and the devices signed in to your account." className="space-y-5">
103 105 <Card>
104 106 <CardHeader title="Change password" description="Use at least 10 characters. A confirmation email is sent after every change." />
105 107 <CardBody>
106 108 <form onSubmit={changePassword} className="space-y-4">
107 109 <Field label="Current password" htmlFor="pw-current">
108 − <Input id="pw-current" type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required />
110 + <Input id="pw-current" type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required className={field} />
109 111 </Field>
110 112 <div className="grid gap-4 sm:grid-cols-2">
111 113 <Field label="New password" htmlFor="pw-next" error={pwTooShort ? "At least 10 characters." : null}>
112 − <Input id="pw-next" type="password" value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" minLength={10} maxLength={128} required />
114 + <Input id="pw-next" type="password" value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" minLength={10} maxLength={128} required className={field} />
113 115 </Field>
114 116 <Field label="Confirm new password" htmlFor="pw-confirm" error={pwMismatch ? "Passwords do not match." : null}>
115 − <Input id="pw-confirm" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
117 + <Input id="pw-confirm" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required className={field} />
116 118 </Field>
117 119 </div>
118 120 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
119 − <label className="flex items-center gap-2.5 text-sm">
121 + <label className="flex min-h-[44px] items-center gap-2.5 text-sm">
120 122 <Switch checked={revokeOthers} onCheckedChange={setRevokeOthers} size="sm" aria-label="Sign out other devices" />
121 123 <span>
122 124 Sign out other devices <span className="text-fg-subtle">(recommended)</span>
123 125 </span>
124 126 </label>
125 − <Button type="submit" loading={pwBusy} disabled={!current || !next || pwMismatch || pwTooShort}>
127 + <Button type="submit" loading={pwBusy} disabled={!current || !next || pwMismatch || pwTooShort} className="h-11 sm:h-9">
126 128 Update password
127 129 </Button>
128 130 </div>
@@ -135,7 +137,7 @@ export default function SecuritySettingsPage() {
135 137 title="Active sessions"
136 138 description="Devices currently signed in to your account."
137 139 action={
138 − <div className="flex gap-2">
140 + <div className="hidden gap-2 sm:flex">
139 141 <Button size="sm" variant="outline" onClick={() => setConfirmAll("others")} disabled={!sessions || sessions.length < 2}>
140 142 <LogOut /> Other devices
141 143 </Button>
@@ -146,6 +148,14 @@ export default function SecuritySettingsPage() {
146 148 }
147 149 />
148 150 <CardBody className="space-y-2">
151 + <div className="flex gap-2 sm:hidden">
152 + <Button variant="outline" className="h-11 flex-1" onClick={() => setConfirmAll("others")} disabled={!sessions || sessions.length < 2}>
153 + <LogOut /> Sign out others
154 + </Button>
155 + <Button variant="danger-soft" className="h-11 flex-1" onClick={() => setConfirmAll("everywhere")}>
156 + Everywhere
157 + </Button>
158 + </div>
149 159 {sessions === null && !sessionsError ? (
150 160 Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-14" />)
151 161 ) : sessionsError ? (
@@ -159,7 +169,7 @@ export default function SecuritySettingsPage() {
159 169 const isCurrent = currentToken ? s.token === currentToken : false;
160 170 const Icon = ua.device === "mobile" ? Smartphone : ua.device === "tablet" ? Tablet : ua.device === "desktop" ? Laptop : Globe;
161 171 return (
162 − <li key={s.id} className="flex items-center gap-3 px-3 py-2.5">
172 + <li key={s.id} className="flex min-h-[56px] items-center gap-3 px-3 py-2.5">
163 173 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-muted text-fg-muted">
164 174 <Icon className="size-4" />
165 175 </span>
@@ -177,7 +187,7 @@ export default function SecuritySettingsPage() {
177 187 </p>
178 188 </div>
179 189 {!isCurrent ? (
180 − <Button size="sm" variant="ghost" loading={revoking === s.token} onClick={() => revokeOne(s.token)} aria-label={`Revoke ${ua.label}`}>
190 + <Button size="sm" variant="ghost" className="tap" loading={revoking === s.token} onClick={() => revokeOne(s.token)} aria-label={`Revoke ${ua.label}`}>
181 191 Revoke
182 192 </Button>
183 193 ) : null}
@@ -207,6 +217,6 @@ export default function SecuritySettingsPage() {
207 217 confirmLabel="Sign out everywhere"
208 218 onConfirm={revokeEverywhere}
209 219 />
210 − </div>
220 + </SettingsSection>
211 221 );
212 222 }
modified src/app/app/usage/page.tsx +6 −269
@@ -1,275 +1,12 @@
1 −"use client";
2 −import * as React from "react";
3 −import Link from "next/link";
4 −import { BarChart3, Info, CheckCircle2, XCircle, CircleStop } from "lucide-react";
5 −import { PageHeader, Stat, EmptyState, Skeleton } from "@/components/ui/misc";
6 −import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
7 −import { Badge } from "@/components/ui/badge";
8 −import { Button } from "@/components/ui/button";
9 −import { Tooltip } from "@/components/ui/tooltip";
10 −import { ProviderIcon } from "@/components/brand/provider-icon";
11 −import { useApp } from "@/components/app/store";
12 −import { useApi } from "@/lib/client/api";
13 −import { providerName, PROVIDERS } from "@/lib/client/providers";
14 −import { formatUsd, formatTokens, formatMs, formatNumber, formatRelative } from "@/lib/utils";
15 −import { errorMessage, formatPercent, formatDateTime } from "@/lib/client/humanize";
16 −import { ChartCard, Legend, CostOverTime, RequestsOverTime, TokensOverTime, ProviderDonut, ModelBars, type SeriesPoint, type ProviderSlice, type ModelRow } from "@/components/usage/charts";
17 −import type { ProviderId } from "@/lib/client/types";
1 +import { Suspense } from "react";
2 +import { UsageDashboard } from "@/components/usage/usage-dashboard";
18 3
19 −type Range = "today" | "7d" | "30d" | "all";
20 −const RANGES: { value: Range; label: string }[] = [
21 − { value: "today", label: "Today" },
22 − { value: "7d", label: "7 days" },
23 − { value: "30d", label: "30 days" },
24 − { value: "all", label: "All time" },
25 −];
26 −
27 −interface UsageResponse {
28 − range: Range;
29 − totals: { requests: number; failures: number; inputTokens: number; outputTokens: number; cachedTokens: number; reasoningTokens: number; costUsd: number; avgLatencyMs: number; avgTtftMs: number };
30 − series: SeriesPoint[];
31 − byProvider: ProviderSlice[];
32 − byModel: { modelKey: string; provider: string; requests: number; inputTokens: number; outputTokens: number; reasoningTokens: number; costUsd: number; avgLatencyMs: number; avgTtftMs: number; tokensPerSec: number; failures: number }[];
33 − recent: {
34 − id: string;
35 − provider: ProviderId;
36 − modelKey: string;
37 − kind: string;
38 − status: string;
39 − errorCode: string | null;
40 − inputTokens: number;
41 − outputTokens: number;
42 − cachedTokens: number;
43 − reasoningTokens: number;
44 − costUsd: number | null;
45 − latencyMs: number | null;
46 − ttftMs: number | null;
47 − conversationId: string | null;
48 − createdAt: string;
49 − }[];
50 −}
4 +export const metadata = { title: "Usage" };
51 5
52 6 export default function UsagePage() {
53 − const [range, setRange] = React.useState<Range>("30d");
54 − const { modelsByKey } = useApp();
55 − const q = useApi<UsageResponse>(`/api/usage?range=${range}`, { keepPreviousData: true });
56 − const data = q.data;
57 − const hourly = range === "today";
58 −
59 − const modelRows = React.useMemo<ModelRow[]>(
60 − () =>
61 − (data?.byModel ?? []).map((m) => ({
62 − modelKey: m.modelKey,
63 − provider: m.provider,
64 − label: modelsByKey.get(m.modelKey)?.displayName ?? m.modelKey.split("/")[1] ?? m.modelKey,
65 − requests: m.requests,
66 − costUsd: m.costUsd,
67 − inputTokens: m.inputTokens,
68 − outputTokens: m.outputTokens,
69 − tokensPerSec: m.tokensPerSec,
70 − avgLatencyMs: m.avgLatencyMs,
71 − failures: m.failures,
72 − })),
73 − [data?.byModel, modelsByKey],
74 − );
75 −
76 − const totals = data?.totals;
77 − const failureRate = totals && totals.requests ? totals.failures / totals.requests : 0;
78 − const empty = data && totals && totals.requests === 0;
79 − const providersUsed = React.useMemo(() => [...new Set((data?.byProvider ?? []).map((p) => p.provider))], [data?.byProvider]);
80 −
81 7 return (
82 − <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
83 − <div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
84 − <PageHeader
85 − title="Usage"
86 − description={
87 − <span className="inline-flex flex-wrap items-center gap-1.5">
88 − Requests, tokens and latency across your providers.
89 − <Tooltip content="Costs are estimates computed from public list prices at request time. Your provider's invoice is the source of truth; cached and batch discounts may differ.">
90 − <span className="inline-flex cursor-help items-center gap-1 text-fg-subtle underline decoration-dotted underline-offset-2">
91 − <Info className="size-3.5" /> Costs are estimates
92 − </span>
93 − </Tooltip>
94 − </span>
95 − }
96 − actions={
97 − <Tabs value={range} onValueChange={(v) => setRange(v as Range)}>
98 − <TabsList aria-label="Time range">
99 − {RANGES.map((r) => (
100 − <TabsTrigger key={r.value} value={r.value}>
101 − {r.label}
102 − </TabsTrigger>
103 − ))}
104 − </TabsList>
105 − </Tabs>
106 − }
107 − />
108 −
109 − {q.error ? (
110 − <div className="mt-6">
111 − <EmptyState title="Could not load usage" description={errorMessage(q.error)} />
112 − </div>
113 − ) : !data ? (
114 − <div className="mt-6 space-y-4">
115 − <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
116 − {Array.from({ length: 8 }).map((_, i) => (
117 − <Skeleton key={i} className="h-[76px]" />
118 − ))}
119 − </div>
120 − <div className="grid gap-3 lg:grid-cols-2">
121 − <Skeleton className="h-64" />
122 − <Skeleton className="h-64" />
123 − </div>
124 − </div>
125 − ) : empty ? (
126 − <div className="mt-6">
127 − <EmptyState
128 − icon={<BarChart3 />}
129 − title={range === "all" ? "No usage yet" : `No usage in the last ${RANGES.find((r) => r.value === range)?.label.toLowerCase()}`}
130 − description="Every chat and Arena request is recorded here with tokens, latency and an estimated cost."
131 − action={
132 − <div className="flex gap-2">
133 − {range !== "all" ? (
134 − <Button variant="outline" size="sm" onClick={() => setRange("all")}>
135 − Show all time
136 − </Button>
137 − ) : null}
138 − <Button size="sm" asChild>
139 − <Link href="/app/chat">Start a chat</Link>
140 − </Button>
141 − </div>
142 − }
143 − />
144 − </div>
145 − ) : (
146 − <div className={`mt-6 space-y-5 transition-opacity ${q.isValidating ? "opacity-70" : ""}`}>
147 − {/* Stat tiles */}
148 − <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
149 − <Stat label="Requests" value={formatNumber(totals!.requests)} hint={`${formatNumber(totals!.failures)} failed`} />
150 − <Stat label="Input tokens" value={formatTokens(totals!.inputTokens)} hint={totals!.cachedTokens ? `${formatTokens(totals!.cachedTokens)} cached` : "no cache hits"} />
151 − <Stat label="Output tokens" value={formatTokens(totals!.outputTokens)} hint={totals!.reasoningTokens ? `${formatTokens(totals!.reasoningTokens)} reasoning` : "no reasoning tokens"} />
152 − <Stat label="Estimated cost" value={formatUsd(totals!.costUsd)} hint="from public list prices" />
153 − <Stat label="Cached tokens" value={formatTokens(totals!.cachedTokens)} hint={totals!.inputTokens ? `${formatPercent(totals!.cachedTokens / totals!.inputTokens, 0)} of input` : undefined} />
154 − <Stat label="Reasoning tokens" value={formatTokens(totals!.reasoningTokens)} hint="billed as output" />
155 − <Stat label="Avg latency" value={formatMs(totals!.avgLatencyMs)} hint={totals!.avgTtftMs ? `first token ${formatMs(totals!.avgTtftMs)}` : undefined} />
156 − <Stat label="Failure rate" value={formatPercent(failureRate)} hint={failureRate > 0.05 ? "check provider status" : "healthy"} className={failureRate > 0.05 ? "border-warning/40" : undefined} />
157 − </div>
158 −
159 − {/* Time series */}
160 − <div className="grid gap-3 lg:grid-cols-2">
161 − <ChartCard title="Estimated cost over time" hint={hourly ? "Per hour, today" : "Per day"}>
162 − <CostOverTime data={data.series} hourly={hourly} />
163 − </ChartCard>
164 − <ChartCard title="Requests over time" hint={hourly ? "Per hour, today" : "Per day"}>
165 − <RequestsOverTime data={data.series} hourly={hourly} />
166 − </ChartCard>
167 − </div>
168 −
169 − <div className="grid gap-3 lg:grid-cols-5">
170 − <ChartCard
171 − title="Tokens in / out"
172 − hint="Stacked per period"
173 − className="lg:col-span-3"
174 − legend={
175 − <Legend
176 − items={[
177 − { label: "Input", color: "var(--accent)", opacity: 0.55 },
178 − { label: "Output", color: "var(--fg)", opacity: 0.85 },
179 − ]}
180 − />
181 − }
182 − >
183 − <TokensOverTime data={data.series} hourly={hourly} />
184 − </ChartCard>
185 − <ChartCard title="Providers" hint="Share of requests" className="lg:col-span-2" legend={<Legend items={providersUsed.map((p) => ({ label: providerName(p), color: p in PROVIDERS ? PROVIDERS[p as ProviderId].colorVar : "var(--fg-subtle)", icon: <ProviderIcon provider={p} size={11} /> }))} />}>
186 − <ProviderDonut data={data.byProvider} metric="requests" />
187 − </ChartCard>
188 − </div>
189 −
190 − {/* Per-model */}
191 − <div className="grid gap-3 lg:grid-cols-3">
192 − <ChartCard title="Most-used models" hint="By requests">
193 − <ModelBars data={modelRows} metric="requests" format={(v) => formatNumber(v)} />
194 − </ChartCard>
195 − <ChartCard title="Cost by model" hint="Estimated, USD">
196 − <ModelBars data={modelRows} metric="costUsd" format={(v) => formatUsd(v)} />
197 − </ChartCard>
198 − <ChartCard title="Fastest models" hint="Output tokens per second">
199 − <ModelBars data={modelRows} metric="tokensPerSec" format={(v) => `${v.toFixed(0)} t/s`} />
200 − </ChartCard>
201 − </div>
202 −
203 − {/* Recent requests */}
204 − <section className="rounded-xl border border-border bg-bg-elevated" aria-label="Recent requests">
205 − <header className="flex items-center justify-between px-4 py-3">
206 − <h3 className="text-[13px] font-semibold tracking-tight">Recent requests</h3>
207 − <span className="text-[11px] text-fg-subtle">Last {data.recent.length}</span>
208 − </header>
209 − <div className="overflow-x-auto">
210 − <table className="w-full min-w-[720px] text-[13px]">
211 − <thead>
212 − <tr className="border-y border-border bg-bg-subtle/60 text-left text-[11px] font-medium uppercase tracking-wide text-fg-subtle">
213 − <th className="px-4 py-2">Time</th>
214 − <th className="px-3 py-2">Model</th>
215 − <th className="px-3 py-2">Kind</th>
216 − <th className="px-3 py-2 text-right">Tokens in / out</th>
217 − <th className="px-3 py-2 text-right">Est. cost</th>
218 − <th className="px-3 py-2 text-right">Latency</th>
219 − <th className="px-4 py-2 text-right">Status</th>
220 − </tr>
221 − </thead>
222 − <tbody>
223 − {data.recent.map((r) => {
224 − const m = modelsByKey.get(r.modelKey);
225 − return (
226 − <tr key={r.id} className="border-b border-border last:border-b-0 hover:bg-bg-subtle/50">
227 − <td className="px-4 py-2 text-fg-muted">
228 − <Tooltip content={formatDateTime(r.createdAt, { year: "numeric", second: "2-digit" })}>
229 − <span>{formatRelative(r.createdAt)}</span>
230 − </Tooltip>
231 − </td>
232 − <td className="px-3 py-2">
233 − <span className="flex items-center gap-1.5">
234 − <ProviderIcon provider={r.provider} size={13} />
235 − <span className="truncate font-medium">{m?.displayName ?? r.modelKey.split("/")[1]}</span>
236 − <span className="hidden text-[11px] text-fg-subtle lg:inline">{providerName(r.provider)}</span>
237 − </span>
238 − </td>
239 − <td className="px-3 py-2 text-fg-muted capitalize">{r.kind}</td>
240 − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">
241 − {formatTokens(r.inputTokens)} / {formatTokens(r.outputTokens)}
242 − </td>
243 − <td className="px-3 py-2 text-right font-mono tabular-nums">{formatUsd(r.costUsd, { precise: true })}</td>
244 − <td className="px-3 py-2 text-right font-mono tabular-nums text-fg-muted">{formatMs(r.latencyMs)}</td>
245 − <td className="px-4 py-2 text-right">
246 − {r.status === "ok" ? (
247 − <Badge variant="success">
248 − <CheckCircle2 /> ok
249 − </Badge>
250 − ) : r.status === "stopped" ? (
251 − <Badge>
252 − <CircleStop /> stopped
253 − </Badge>
254 − ) : (
255 − <Tooltip content={r.errorCode ?? undefined}>
256 − <Badge variant="danger">
257 − <XCircle /> {r.errorCode ?? "error"}
258 − </Badge>
259 − </Tooltip>
260 − )}
261 − </td>
262 − </tr>
263 − );
264 − })}
265 − </tbody>
266 − </table>
267 − </div>
268 − </section>
269 − <p className="text-[11px] text-fg-subtle">All costs on this page are estimates from public list prices at request time. Billing happens on your provider accounts.</p>
270 − </div>
271 − )}
272 − </div>
273 − </main>
8 + <Suspense fallback={<main className="min-h-0 flex-1" aria-busy="true" />}>
9 + <UsageDashboard />
10 + </Suspense>
274 11 );
275 12 }
modified src/app/share/[id]/page.tsx +94 −62
@@ -1,9 +1,9 @@
1 1 import type { Metadata } from "next";
2 2 import Link from "next/link";
3 3 import { notFound } from "next/navigation";
4 −import { ArrowRight, Brain, Link2, Wrench } from "lucide-react";
5 −import { getPublicShare } from "@/lib/conversations/service";
6 −import { Logo } from "@/components/brand/logo";
4 +import { ArrowRight, Brain, Eye, Link2, MessagesSquare, Scissors, Wrench } from "lucide-react";
5 +import { getPublicShare, readShareMeta } from "@/lib/conversations/service";
6 +import { LogoMark, Wordmark } from "@/components/brand/logo";
7 7 import { ProviderIcon } from "@/components/brand/provider-icon";
8 8 import { Badge } from "@/components/ui/badge";
9 9 import { Button } from "@/components/ui/button";
@@ -14,7 +14,8 @@ import { formatMs, formatTokens, cn } from "@/lib/utils";
14 14 export const dynamic = "force-dynamic";
15 15
16 16 /* ------------------------------------------------------------------------------------------------
17 − * Snapshot shape (frozen copy written by `shareConversation`). Parsed defensively.
17 + * Snapshot shape (frozen copy written by `shareConversation`). Parsed defensively; the optional first
18 + * `$meta` element (v2) is skipped by the message parser and read separately with `readShareMeta`.
18 19 * ---------------------------------------------------------------------------------------------- */
19 20 type SnapPart =
20 21 | { type: "text"; text: string }
@@ -77,30 +78,33 @@ function formatDate(d: Date) {
77 78 }
78 79
79 80 /* ------------------------------------------------------------------------------------------------
80 − * Metadata
81 + * Metadata — noindex, per-share OG title
81 82 * ---------------------------------------------------------------------------------------------- */
82 83 export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
83 84 const { id } = await params;
84 − const share = await getPublicShareSafe(id);
85 + const share = await getPublicShareSafe(id, { peek: true });
86 + const count = share ? parseSnapshot(share.snapshot).filter((m) => m.role !== "system").length : 0;
87 + const description = share ? `A conversation shared from PolyLLM · ${count} message${count === 1 ? "" : "s"}.` : "This shared conversation is unavailable.";
85 88 return {
86 89 title: share ? share.title : "Shared conversation",
87 − description: share ? `A conversation shared from PolyLLM (${parseSnapshot(share.snapshot).length} messages).` : "This shared conversation is unavailable.",
88 − robots: { index: false, follow: false, nocache: true },
89 − openGraph: share ? { title: share.title, description: "Shared from PolyLLM", type: "article" } : undefined,
90 + description,
91 + robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
92 + openGraph: share ? { title: `${share.title} · Shared from PolyLLM`, description, type: "article", siteName: "PolyLLM" } : undefined,
93 + twitter: share ? { card: "summary", title: `${share.title} · Shared from PolyLLM`, description } : undefined,
90 94 };
91 95 }
92 96
93 −async function getPublicShareSafe(id: string) {
97 +async function getPublicShareSafe(id: string, opts: { peek?: boolean } = {}) {
94 98 if (!id || id.length > 128 || !/^[\w-]+$/.test(id)) return null;
95 99 try {
96 − return await getPublicShare(id);
100 + return await getPublicShare(id, opts);
97 101 } catch {
98 102 return null;
99 103 }
100 104 }
101 105
102 106 /* ------------------------------------------------------------------------------------------------
103 − * Page
107 + * Page — mobile-first: 16px gutters, stacked header, full-width CTA; widens at sm/md.
104 108 * ---------------------------------------------------------------------------------------------- */
105 109 export default async function SharePage({ params }: { params: Promise<{ id: string }> }) {
106 110 const { id } = await params;
@@ -108,72 +112,100 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
108 112 if (!share) notFound();
109 113
110 114 const messages = parseSnapshot(share.snapshot).filter((m) => m.role !== "system");
115 + const meta = readShareMeta(share.snapshot);
111 116 const models = Array.from(new Set(messages.map((m) => m.modelKey).filter((k): k is string => !!k)));
112 117 const created = share.createdAt instanceof Date ? share.createdAt : new Date(share.createdAt as unknown as string);
118 + const views = share.viewCount + 1; // this visit was counted after the row was read
113 119
114 120 return (
115 − <div className="flex min-h-dvh flex-col">
116 − <header className="glass sticky top-0 z-40 border-b border-border">
117 − <div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between gap-3 px-4 sm:px-6">
118 − <Link href="/" className="flex items-center gap-2.5 rounded-md" aria-label="PolyLLM home">
119 − <Logo size={22} />
120 − <span className="hidden text-[13px] text-fg-muted sm:inline">· Shared from PolyLLM</span>
121 + <div className="flex min-h-dvh flex-col bg-bg">
122 + <header className="glass sticky top-0 z-40 pt-[var(--sat)] hairline-b">
123 + <div className="mx-auto flex h-12 w-full max-w-3xl items-center justify-between gap-3 px-4 sm:h-14 sm:px-6">
124 + <Link href="/" className="flex min-w-0 items-center gap-2 rounded-md" aria-label="PolyLLM home">
125 + <LogoMark size={24} />
126 + <Wordmark className="text-[16px]" />
127 + <span className="hidden truncate text-[12.5px] text-fg-subtle sm:inline">· Shared conversation</span>
121 128 </Link>
122 − <Button asChild size="sm">
129 + <Button asChild size="sm" className="shrink-0">
123 130 <Link href="/signup">
124 − Start using PolyLLM
131 + Open PolyLLM
125 132 <ArrowRight />
126 133 </Link>
127 134 </Button>
128 135 </div>
129 136 </header>
130 137
131 − <main className="mx-auto w-full max-w-3xl flex-1 px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
132 − <header className="border-b border-border pb-6">
133 − <p className="font-mono text-[11px] uppercase tracking-[0.14em] text-fg-subtle">Shared conversation</p>
134 − <h1 className="mt-2 text-balance text-2xl font-semibold leading-tight tracking-tight sm:text-3xl">{share.title}</h1>
135 − <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-[13px] text-fg-muted">
136 − <time dateTime={created.toISOString()}>{formatDate(created)}</time>
137 − <span aria-hidden>·</span>
138 − <span>
139 − {messages.length} message{messages.length === 1 ? "" : "s"}
140 − </span>
141 − {models.length ? (
142 − <>
143 − <span aria-hidden>·</span>
144 − <ul className="flex flex-wrap gap-1.5" aria-label="Models used">
145 − {models.map((k) => {
146 − const { provider, model } = splitModelKey(k);
147 − return (
148 − <li key={k}>
149 − <Badge>
150 − <ProviderIcon provider={provider} size={12} />
151 − {model}
152 − </Badge>
153 − </li>
154 − );
155 − })}
156 − </ul>
157 − </>
138 + <main className="mx-auto w-full max-w-3xl flex-1 px-4 pb-14 pt-6 sm:px-6 sm:pt-10">
139 + <header className="pb-5 hairline-b sm:pb-6">
140 + <p className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.14em] text-fg-subtle">
141 + <MessagesSquare className="size-3.5" aria-hidden />
142 + Shared from PolyLLM
143 + {meta?.partial ? (
144 + <Badge variant="accent" className="ml-1 normal-case tracking-normal">
145 + <Scissors />
146 + Excerpt · {meta.selectedCount} of {meta.totalCount}
147 + </Badge>
158 148 ) : null}
159 − </div>
149 + </p>
150 + <h1 className="mt-2 text-balance text-[24px] font-semibold leading-tight tracking-tight sm:text-3xl">{share.title}</h1>
151 + <dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[13px] text-fg-muted">
152 + <div className="flex items-center gap-1.5">
153 + <dt className="sr-only">Shared on</dt>
154 + <dd>
155 + <time dateTime={created.toISOString()}>{formatDate(created)}</time>
156 + </dd>
157 + </div>
158 + <span aria-hidden>·</span>
159 + <div>
160 + <dt className="sr-only">Messages</dt>
161 + <dd>
162 + {messages.length} message{messages.length === 1 ? "" : "s"}
163 + </dd>
164 + </div>
165 + <span aria-hidden>·</span>
166 + <div className="flex items-center gap-1 tabular-nums">
167 + <Eye className="size-3.5 text-fg-subtle" aria-hidden />
168 + <dt className="sr-only">Views</dt>
169 + <dd>
170 + {views} view{views === 1 ? "" : "s"}
171 + </dd>
172 + </div>
173 + </dl>
174 + {models.length ? (
175 + <ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Models used">
176 + {models.map((k) => {
177 + const { provider, model } = splitModelKey(k);
178 + return (
179 + <li key={k}>
180 + <Badge className="h-7 px-2.5 text-[12px]">
181 + <ProviderIcon provider={provider} size={13} />
182 + {model}
183 + </Badge>
184 + </li>
185 + );
186 + })}
187 + </ul>
188 + ) : null}
160 189 </header>
161 190
162 − <ol className="mt-8 space-y-7">
191 + <ol className="mt-6 space-y-6 sm:mt-8 sm:space-y-7">
163 192 {messages.map((m, i) => (
164 − <li key={i}>
193 + <li key={i} id={`m-${i + 1}`}>
165 194 <MessageView message={m} />
166 195 </li>
167 196 ))}
168 197 </ol>
169 198
170 − <aside className="mt-14 rounded-2xl border border-border bg-bg-elevated p-6 text-center shadow-sm sm:p-8">
171 − <p className="text-lg font-semibold tracking-tight">Continue this conversation with your own keys</p>
172 − <p className="mx-auto mt-2 max-w-md text-[14px] leading-6 text-fg-muted">PolyLLM is a free workspace for OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras models. Bring your API keys, chat, compare and track costs.</p>
173 − <div className="mt-5 flex flex-col items-center justify-center gap-2 sm:flex-row">
199 + <aside className="panel mt-12 p-5 text-center sm:mt-14 sm:p-8">
200 + <div className="mx-auto flex size-10 items-center justify-center">
201 + <LogoMark size={36} />
202 + </div>
203 + <p className="mt-3 text-balance text-[17px] font-semibold tracking-tight sm:text-lg">Continue this conversation with your own keys</p>
204 + <p className="mx-auto mt-2 max-w-md text-balance text-[14px] leading-6 text-fg-muted">One interface, every model. Bring your OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter or Cerebras key — chat, compare in the Arena and track every token.</p>
205 + <div className="mt-5 flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center">
174 206 <Button asChild size="lg" className="w-full sm:w-auto">
175 207 <Link href="/signup">
176 − Create a free account
208 + Open PolyLLM
177 209 <ArrowRight />
178 210 </Link>
179 211 </Button>
@@ -184,9 +216,9 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
184 216 </aside>
185 217 </main>
186 218
187 − <footer className="border-t border-border py-6 text-center text-xs text-fg-subtle">
188 − <p>
189 − This is a frozen snapshot shared by a PolyLLM user. Model output can be wrong.{" "}
219 + <footer className="px-4 pb-[max(20px,var(--sab))] pt-6 text-center text-xs text-fg-subtle hairline-t">
220 + <p className="text-balance">
221 + Frozen snapshot shared by a PolyLLM user · attachments are not included · model output can be wrong.{" "}
190 222 <Link href="/privacy" className="underline-offset-4 hover:text-fg hover:underline">
191 223 Privacy
192 224 </Link>
@@ -208,7 +240,7 @@ function MessageView({ message }: { message: SnapMessage }) {
208 240 const text = message.parts.filter((p): p is Extract<SnapPart, { type: "text" }> => p.type === "text").map((p) => p.text).join("\n\n") || message.content;
209 241 return (
210 242 <div className="flex justify-end">
211 − <div className="max-w-[88%] rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-7 sm:max-w-[80%]">
243 + <div className="max-w-[92%] rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-7 sm:max-w-[80%]">
212 244 <p className="sr-only">You said:</p>
213 245 <SimpleMarkdown>{text}</SimpleMarkdown>
214 246 </div>
@@ -225,19 +257,19 @@ function MessageView({ message }: { message: SnapMessage }) {
225 257 const totalTokens = message.usage?.totalTokens ?? ((message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0) || null);
226 258
227 259 return (
228 − <article className="flex gap-3">
260 + <article className="flex gap-2.5 sm:gap-3">
229 261 <span className="mt-1 flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-bg-elevated" aria-hidden>
230 262 <ProviderIcon provider={provider} size={14} />
231 263 </span>
232 264 <div className="min-w-0 flex-1">
233 − <div className="flex flex-wrap items-center gap-2 text-[13px]">
265 + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[13px]">
234 266 <span className="font-medium">{model ?? "Assistant"}</span>
235 267 {provider ? <span className="text-fg-subtle">{providerName(provider)}</span> : null}
236 268 </div>
237 269
238 270 {reasoning.length ? (
239 271 <details className="group mt-2 rounded-lg border border-border bg-bg-subtle/60">
240 − <summary className="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-[13px] text-fg-muted marker:content-none [&::-webkit-details-marker]:hidden">
272 + <summary className="flex min-h-[40px] cursor-pointer select-none items-center gap-2 px-3 py-2 text-[13px] text-fg-muted marker:content-none [&::-webkit-details-marker]:hidden">
241 273 <Brain className="size-3.5" aria-hidden />
242 274 Reasoning
243 275 {reasoning[0].durationMs ? <span className="font-mono text-[11px] text-fg-subtle">· {formatMs(reasoning[0].durationMs)}</span> : null}
added src/app/share/arena/[id]/page.tsx +216 −0
@@ -0,0 +1,216 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { ArrowRight, Paperclip, Swords } from "lucide-react";
5 +import { getPublicArenaShare } from "@/lib/arena/service";
6 +import type { ArenaShareSnapshot } from "@/lib/arena/export";
7 +import { Logo } from "@/components/brand/logo";
8 +import { ProviderIcon } from "@/components/brand/provider-icon";
9 +import { Badge } from "@/components/ui/badge";
10 +import { Button } from "@/components/ui/button";
11 +import { SimpleMarkdown } from "@/components/markdown/simple-markdown";
12 +import { BlindNotice, SharedArenaView } from "@/components/arena/shared-arena-view";
13 +
14 +export const dynamic = "force-dynamic";
15 +
16 +function isRecord(v: unknown): v is Record<string, unknown> {
17 + return typeof v === "object" && v !== null;
18 +}
19 +
20 +/** Defensive parse of the frozen snapshot written by `shareArenaSession`. */
21 +function parseSnapshot(raw: unknown): ArenaShareSnapshot | null {
22 + if (!isRecord(raw) || typeof raw.prompt !== "string" || !Array.isArray(raw.responses)) return null;
23 + const responses = raw.responses.filter(isRecord).map((r, i) => ({
24 + id: typeof r.id === "string" ? r.id : `r${i}`,
25 + modelKey: typeof r.modelKey === "string" ? r.modelKey : "",
26 + provider: typeof r.provider === "string" ? r.provider : (typeof r.modelKey === "string" ? r.modelKey.split("/")[0] : ""),
27 + displayName: typeof r.displayName === "string" ? r.displayName : typeof r.modelKey === "string" ? r.modelKey.split("/").slice(1).join("/") : "Model",
28 + status: typeof r.status === "string" ? r.status : "complete",
29 + content: typeof r.content === "string" ? r.content : "",
30 + reasoning: typeof r.reasoning === "string" ? r.reasoning : null,
31 + error: isRecord(r.error) && typeof r.error.message === "string" ? { code: String(r.error.code ?? "ERROR"), message: r.error.message } : null,
32 + ttftMs: typeof r.ttftMs === "number" ? r.ttftMs : null,
33 + latencyMs: typeof r.latencyMs === "number" ? r.latencyMs : null,
34 + costUsd: typeof r.costUsd === "number" ? r.costUsd : null,
35 + usage: isRecord(r.usage) ? (r.usage as ArenaShareSnapshot["responses"][number]["usage"]) : null,
36 + criteriaWon: Array.isArray(r.criteriaWon) ? r.criteriaWon.filter((c): c is string => typeof c === "string") : [],
37 + }));
38 + const models = Array.isArray(raw.models) ? raw.models.filter(isRecord).map((m) => ({ key: String(m.key ?? ""), provider: String(m.provider ?? ""), displayName: String(m.displayName ?? m.key ?? "") })) : responses.map((r) => ({ key: r.modelKey, provider: r.provider, displayName: r.displayName }));
39 + const votes = Array.isArray(raw.votes) ? raw.votes.filter(isRecord).map((v) => ({ criterion: String(v.criterion ?? ""), label: String(v.label ?? v.criterion ?? ""), modelKey: String(v.modelKey ?? ""), responseId: String(v.responseId ?? "") })) : [];
40 + const w = isRecord(raw.winner) ? raw.winner : null;
41 + const winner =
42 + w && typeof w.modelKey === "string"
43 + ? {
44 + modelKey: w.modelKey,
45 + responseId: String(w.responseId ?? ""),
46 + criteriaWon: Array.isArray(w.criteriaWon) ? w.criteriaWon.filter((c): c is string => typeof c === "string") : [],
47 + tieBreak: w.tieBreak === "fastest" ? ("fastest" as const) : w.tieBreak === "order" ? ("order" as const) : null,
48 + deltas: isRecord(w.deltas) ? { costUsd: num(w.deltas.costUsd), ttftMs: num(w.deltas.ttftMs), latencyMs: num(w.deltas.latencyMs), outputTokens: num(w.deltas.outputTokens), others: num(w.deltas.others) ?? 0 } : { costUsd: null, ttftMs: null, latencyMs: null, outputTokens: null, others: 0 },
49 + }
50 + : null;
51 + return {
52 + version: 1,
53 + prompt: raw.prompt,
54 + systemPrompt: typeof raw.systemPrompt === "string" ? raw.systemPrompt : null,
55 + blind: raw.blind === true,
56 + attachmentCount: typeof raw.attachmentCount === "number" ? raw.attachmentCount : 0,
57 + parameters: isRecord(raw.parameters) ? raw.parameters : {},
58 + createdAt: typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString(),
59 + models,
60 + responses,
61 + votes,
62 + winner,
63 + };
64 +}
65 +
66 +function num(v: unknown): number | null {
67 + return typeof v === "number" && Number.isFinite(v) ? v : null;
68 +}
69 +
70 +async function getShareSafe(id: string) {
71 + if (!id || id.length > 128 || !/^[\w-]+$/.test(id)) return null;
72 + try {
73 + const row = await getPublicArenaShare(id);
74 + if (!row) return null;
75 + const snapshot = parseSnapshot(row.snapshot);
76 + return snapshot ? { row, snapshot } : null;
77 + } catch {
78 + return null;
79 + }
80 +}
81 +
82 +function title(s: ArenaShareSnapshot): string {
83 + const names = s.models.map((m) => m.displayName);
84 + return names.length <= 2 ? names.join(" vs ") : `${names.slice(0, 2).join(" vs ")} + ${names.length - 2} more`;
85 +}
86 +
87 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
88 + const { id } = await params;
89 + const share = await getShareSafe(id);
90 + return {
91 + title: share ? `${title(share.snapshot)} — Arena comparison` : "Shared Arena comparison",
92 + description: share ? share.snapshot.prompt.slice(0, 160) : "This shared comparison is unavailable.",
93 + robots: { index: false, follow: false, nocache: true },
94 + openGraph: share ? { title: `${title(share.snapshot)} — PolyLLM Arena`, description: share.snapshot.prompt.slice(0, 200), type: "article" } : undefined,
95 + };
96 +}
97 +
98 +export default async function SharedArenaPage({ params }: { params: Promise<{ id: string }> }) {
99 + const { id } = await params;
100 + const share = await getShareSafe(id);
101 + if (!share) notFound();
102 + const s = share.snapshot;
103 + const created = new Date(s.createdAt);
104 + const params_ = Object.entries(s.parameters).filter(([, v]) => v !== undefined && v !== null);
105 +
106 + return (
107 + <div className="flex min-h-dvh flex-col">
108 + <header className="glass sticky top-0 z-40 border-b border-border">
109 + <div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-4 sm:px-6">
110 + <Link href="/" className="flex items-center gap-2.5 rounded-md" aria-label="PolyLLM home">
111 + <Logo size={22} />
112 + <span className="hidden text-[13px] text-fg-muted sm:inline">· Shared Arena comparison</span>
113 + </Link>
114 + <Button asChild size="sm">
115 + <Link href="/app/arena">
116 + Try the Arena
117 + <ArrowRight />
118 + </Link>
119 + </Button>
120 + </div>
121 + </header>
122 +
123 + <main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-16 pt-6 sm:px-6 sm:pt-10">
124 + <section className="border-b border-border pb-5">
125 + <p className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] text-fg-subtle">
126 + <Swords className="size-3.5" /> Arena · {s.models.length} model{s.models.length === 1 ? "" : "s"}
127 + </p>
128 + <h1 className="mt-2 text-balance text-xl font-semibold leading-tight tracking-tight sm:text-2xl">{title(s)}</h1>
129 + <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-[13px] text-fg-muted">
130 + <time dateTime={created.toISOString()}>{created.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</time>
131 + <span aria-hidden>·</span>
132 + <ul className="flex flex-wrap gap-1.5" aria-label="Models compared">
133 + {s.models.map((m) => (
134 + <li key={m.key}>
135 + <Badge>
136 + <ProviderIcon provider={m.provider} size={12} />
137 + {m.displayName}
138 + </Badge>
139 + </li>
140 + ))}
141 + </ul>
142 + {s.blind ? <BlindNotice /> : null}
143 + </div>
144 + </section>
145 +
146 + <section className="mt-6 space-y-3" aria-label="Prompt">
147 + <div className="flex justify-end">
148 + <div className="max-w-full rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-7 sm:max-w-[80%]">
149 + <p className="sr-only">Prompt:</p>
150 + <SimpleMarkdown>{s.prompt}</SimpleMarkdown>
151 + </div>
152 + </div>
153 + {s.systemPrompt || s.attachmentCount || params_.length ? (
154 + <details className="text-[12.5px] text-fg-muted">
155 + <summary className="cursor-pointer select-none">Settings</summary>
156 + <div className="mt-2 space-y-2 rounded-lg border border-border bg-bg-subtle/60 px-3 py-2">
157 + {s.systemPrompt ? (
158 + <p>
159 + <span className="font-medium text-fg">System prompt:</span> {s.systemPrompt}
160 + </p>
161 + ) : null}
162 + {s.attachmentCount ? (
163 + <p className="inline-flex items-center gap-1.5">
164 + <Paperclip className="size-3.5" /> {s.attachmentCount} attachment{s.attachmentCount === 1 ? "" : "s"} (not published)
165 + </p>
166 + ) : null}
167 + {params_.length ? (
168 + <ul className="flex flex-wrap gap-1.5">
169 + {params_.map(([k, v]) => (
170 + <li key={k} className="rounded-md border border-border bg-bg px-1.5 py-0.5 font-mono text-[11px]">
171 + {k}: {typeof v === "object" ? JSON.stringify(v) : String(v)}
172 + </li>
173 + ))}
174 + </ul>
175 + ) : null}
176 + </div>
177 + </details>
178 + ) : null}
179 + </section>
180 +
181 + <section className="mt-6" aria-label="Responses">
182 + <SharedArenaView snapshot={s} />
183 + </section>
184 +
185 + <aside className="mt-14 rounded-2xl border border-border bg-bg-elevated p-6 text-center shadow-sm sm:p-8">
186 + <p className="text-lg font-semibold tracking-tight">Compare models with your own keys</p>
187 + <p className="mx-auto mt-2 max-w-md text-[14px] leading-6 text-fg-muted">PolyLLM Arena runs one prompt through up to four models from OpenAI, Anthropic, Google, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras — live, with real latency and cost.</p>
188 + <div className="mt-5 flex flex-col items-center justify-center gap-2 sm:flex-row">
189 + <Button asChild size="lg" className="w-full sm:w-auto">
190 + <Link href="/signup">
191 + Create a free account
192 + <ArrowRight />
193 + </Link>
194 + </Button>
195 + <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">
196 + <Link href="/">Learn more</Link>
197 + </Button>
198 + </div>
199 + </aside>
200 + </main>
201 +
202 + <footer className="border-t border-border py-6 text-center text-xs text-fg-subtle">
203 + <p>
204 + Frozen snapshot shared by a PolyLLM user. Costs are estimates from list prices; model output can be wrong.{" "}
205 + <Link href="/privacy" className="underline-offset-4 hover:text-fg hover:underline">
206 + Privacy
207 + </Link>
208 + {" · "}
209 + <Link href="/terms" className="underline-offset-4 hover:text-fg hover:underline">
210 + Terms
211 + </Link>
212 + </p>
213 + </footer>
214 + </div>
215 + );
216 +}
modified src/app/sitemap.ts +3 −0
@@ -5,8 +5,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
5 5 const now = new Date();
6 6 const entries: { path: string; priority: number; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"] }[] = [
7 7 { path: "/", priority: 1, changeFrequency: "weekly" },
8 + { path: "/models", priority: 0.9, changeFrequency: "daily" },
9 + { path: "/security", priority: 0.7, changeFrequency: "monthly" },
8 10 { path: "/signup", priority: 0.8, changeFrequency: "monthly" },
9 11 { path: "/login", priority: 0.6, changeFrequency: "monthly" },
12 + { path: "/contact", priority: 0.4, changeFrequency: "yearly" },
10 13 { path: "/privacy", priority: 0.3, changeFrequency: "yearly" },
11 14 { path: "/terms", priority: 0.3, changeFrequency: "yearly" },
12 15 ];
modified src/components/app/command-palette.tsx +546 −177
@@ -1,218 +1,587 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { useRouter } from "next/navigation";
3 +import { usePathname, useRouter } from "next/navigation";
4 4 import { Command } from "cmdk";
5 −import { BarChart3, Boxes, MessageSquare, Moon, Plus, Search, Settings, ShieldCheck, Sparkles, Sun, Swords, WandSparkles, Monitor } from "lucide-react";
6 5 import { useTheme } from "next-themes";
7 −import { useApp } from "./store";
8 −import { api } from "@/lib/client/api";
6 +import { ArrowLeft, BarChart3, Boxes, Check, ChevronRight, Clock, Download, FolderKanban, Ghost, Keyboard, Library, Link2, MessageSquare, Monitor, Moon, PanelLeft, Paperclip, Plus, Search, SearchX, Settings, Share2, ShieldCheck, Sparkles, Sun, SunMoon, Swords, WandSparkles, Wand2, X } from "lucide-react";
7 +import { useApp, AUTO_MODEL_KEY } from "./store";
8 +import { useApi } from "@/lib/client/api";
9 +import type { Project, SearchGroup } from "@/lib/client/types";
9 10 import { ProviderIcon } from "@/components/brand/provider-icon";
10 −import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
11 −import { Kbd } from "@/components/ui/misc";
12 −
13 −interface SearchResults {
14 − conversations: { id: string; title: string; modelKey: string | null }[];
15 − messages: { id: string; conversationId: string; title: string; snippet: string; role: string }[];
16 − models: { key: string; displayName: string; provider: string }[];
17 − prompts: { id: string; name: string }[];
18 − presets: { id: string; name: string; modelKey: string }[];
11 +import { ResponsiveDialog } from "@/components/ui/sheet";
12 +import { Kbd, Spinner } from "@/components/ui/misc";
13 +import { toast } from "@/components/ui/toast";
14 +import { useIsMobile } from "@/lib/client/hooks";
15 +import { providerName } from "@/lib/client/providers";
16 +import { cn } from "@/lib/utils";
17 +import { SearchSheet } from "@/components/search/search-sheet";
18 +import { useSearch, useRecentSearches, SEARCH_EXAMPLES } from "@/components/search/use-search";
19 +import { ConversationHitContent, MessageHitContent, ModelHitContent, PromptHitContent, ProjectHitContent, PresetHitContent, GROUP_LABELS, GROUP_ORDER, useSearchNavigation } from "@/components/search/hits";
20 +import { FilterChips } from "@/components/search/filter-chips";
21 +import { ShareSheetHost, openShareSheet } from "@/components/share/share-sheet";
22 +import { EXPORT_OPTIONS, exportConversation } from "@/components/share/export";
23 +import { EXPORT_ICONS } from "@/components/share/export-menu";
24 +
25 +/**
26 + * Universal command palette (⌘K) — keyboard-first on desktop, bottom sheet with large rows on phones.
27 + *
28 + * Modes: root commands → sub-lists (Switch model, Open project, Export, Theme). Typing ≥ 2 characters in the
29 + * root shows matching commands AND live search results; a leading `/` forces search mode.
30 + * Backspace on an empty input goes back to the root; Esc closes.
31 + *
32 + * Window events emitted for other workstreams (documented in docs/upgrade-notes/G-search-share.md):
33 + * polyllm:open-attach → composer opens its attachment sheet / file picker
34 + * polyllm:switch-model → { detail: { modelKey } } chat view applies the model to the current conversation
35 + * This component also mounts <SearchSheet /> (phone search) and <ShareSheetHost /> once for the app.
36 + */
37 +type Mode = "root" | "search" | "models" | "projects" | "export" | "theme";
38 +
39 +interface Cmd {
40 + id: string;
41 + label: string;
42 + group: "Actions" | "Navigate" | "Conversation" | "View" | "Help";
43 + icon: React.ReactNode;
44 + keywords?: string;
45 + shortcut?: string;
46 + hint?: string;
47 + /** Opens a sub-list instead of running. */
48 + submode?: Mode;
49 + run?: () => void;
19 50 }
20 51
52 +export const CHAT_ROUTE_RE = /^\/app\/chat\/([^/?#]+)/;
53 +
21 54 export function CommandPalette() {
22 − const { paletteOpen, setPaletteOpen, models, connectedProviders, setSelectedModelKey } = useApp();
55 + return (
56 + <>
57 + <PaletteDialog />
58 + <SearchSheet />
59 + <ShareSheetHost />
60 + </>
61 + );
62 +}
63 +
64 +function PaletteDialog() {
65 + const { paletteOpen, setPaletteOpen, searchOpen, setSearchOpen, setSidebarOpen, models, connectedProviders, favorites, labels, selectedModelKey, setSelectedModelKey, activeProjectId, setActiveProjectId } = useApp();
23 66 const router = useRouter();
24 − const { setTheme } = useTheme();
67 + const pathname = usePathname();
68 + const isMobile = useIsMobile();
69 + const { theme, setTheme } = useTheme();
70 + const [mode, setMode] = React.useState<Mode>("root");
25 71 const [q, setQ] = React.useState("");
26 − const [results, setResults] = React.useState<SearchResults | null>(null);
72 + const [shortcutsOpen, setShortcutsOpen] = React.useState(false);
73 + const inputRef = React.useRef<HTMLInputElement>(null);
74 + const recent = useRecentSearches();
75 + const activeChatId = pathname.match(CHAT_ROUTE_RE)?.[1] ?? null;
27 76
77 + // Reset when closed.
28 78 React.useEffect(() => {
29 79 if (!paletteOpen) {
30 80 // eslint-disable-next-line react-hooks/set-state-in-effect
81 + setMode("root");
31 82 setQ("");
32 − setResults(null);
33 83 }
34 84 }, [paletteOpen]);
35 85
86 + // Desktop: `store.searchOpen` (sidebar search button, bottom nav on tablets) opens the palette in search mode.
36 87 React.useEffect(() => {
37 − if (q.trim().length < 2) {
88 + if (searchOpen && !isMobile) {
38 89 // eslint-disable-next-line react-hooks/set-state-in-effect
39 − setResults(null);
40 − return;
90 + setMode("search");
91 + setSearchOpen(false);
92 + setPaletteOpen(true);
93 + }
94 + }, [searchOpen, isMobile, setSearchOpen, setPaletteOpen]);
95 +
96 + const close = React.useCallback(() => setPaletteOpen(false), [setPaletteOpen]);
97 + const go = React.useCallback(
98 + (href: string) => {
99 + close();
100 + router.push(href);
101 + },
102 + [close, router],
103 + );
104 +
105 + /* ---- search --------------------------------------------------------------------------------- */
106 + const slash = q.startsWith("/");
107 + const searchQuery = mode === "search" ? q : slash ? q.slice(1) : q.trim().length >= 2 ? q : "";
108 + const search = useSearch(searchQuery, { enabled: paletteOpen && searchQuery.length > 0, limit: isMobile ? 8 : 6 });
109 + const nav = useSearchNavigation({
110 + onNavigate: () => {
111 + recent.add(searchQuery);
112 + close();
113 + },
114 + });
115 +
116 + /* ---- commands ------------------------------------------------------------------------------- */
117 + const commands = React.useMemo<Cmd[]>(() => {
118 + const list: Cmd[] = [
119 + { id: "new-chat", label: "New chat", group: "Actions", icon: <Plus />, shortcut: "⌘N", keywords: "create conversation start", run: () => go("/app/chat") },
120 + { id: "temp-chat", label: "New temporary chat", group: "Actions", icon: <Ghost />, keywords: "incognito private ephemeral not stored", hint: "Not stored in history", run: () => go("/app/chat?temporary=1") },
121 + { id: "search", label: "Search conversations", group: "Actions", icon: <Search />, shortcut: "/", keywords: "find messages history", submode: "search" },
122 + { id: "switch-model", label: "Switch model", group: "Actions", icon: <Boxes />, keywords: "change model auto router pick", submode: "models" },
123 + { id: "upload", label: "Upload file", group: "Actions", icon: <Paperclip />, keywords: "attach image document pdf camera", run: () => uploadFile() },
124 + { id: "open-project", label: "Open project", group: "Navigate", icon: <FolderKanban />, keywords: "workspace projects", submode: "projects" },
125 + { id: "nav-chat", label: "Go to Chat", group: "Navigate", icon: <MessageSquare />, run: () => go("/app/chat") },
126 + { id: "nav-arena", label: "Open Arena", group: "Navigate", icon: <Swords />, keywords: "compare battle models side by side", run: () => go("/app/arena") },
127 + { id: "nav-models", label: "Browse models", group: "Navigate", icon: <Boxes />, keywords: "catalog registry", run: () => go("/app/models") },
128 + { id: "nav-usage", label: "Usage & costs", group: "Navigate", icon: <BarChart3 />, keywords: "analytics tokens spend dashboard", run: () => go("/app/usage") },
129 + { id: "nav-providers", label: "Providers & API keys", group: "Navigate", icon: <ShieldCheck />, keywords: "keys connect openai anthropic settings", run: () => go("/app/settings/providers") },
130 + { id: "nav-projects", label: "Projects", group: "Navigate", icon: <FolderKanban />, run: () => go("/app/projects") },
131 + { id: "nav-library", label: "Library", group: "Navigate", icon: <Library />, keywords: "files context documents", run: () => go("/app/library") },
132 + { id: "nav-prompts", label: "Prompts", group: "Navigate", icon: <WandSparkles />, keywords: "prompt library templates presets", run: () => go("/app/prompts") },
133 + { id: "nav-presets", label: "Model presets", group: "Navigate", icon: <Sparkles />, run: () => go("/app/presets") },
134 + { id: "nav-settings", label: "Settings", group: "Navigate", icon: <Settings />, keywords: "account appearance data security", run: () => go("/app/settings/account") },
135 + { id: "theme", label: "Toggle theme", group: "View", icon: <SunMoon />, keywords: "dark light system appearance mode", submode: "theme" },
136 + { id: "sidebar", label: isMobile ? "Open sidebar" : "Toggle sidebar", group: "View", icon: <PanelLeft />, shortcut: isMobile ? undefined : "⌘B", keywords: "collapse expand drawer conversations", run: () => toggleSidebar() },
137 + { id: "shortcuts", label: "Keyboard shortcuts", group: "Help", icon: <Keyboard />, shortcut: "?", keywords: "help keys hotkeys", run: () => { close(); setShortcutsOpen(true); } },
138 + ];
139 + if (activeChatId) {
140 + list.splice(
141 + 5,
142 + 0,
143 + { id: "copy-url", label: "Copy conversation URL", group: "Conversation", icon: <Link2 />, keywords: "link address clipboard", run: () => copyUrl() },
144 + { id: "share", label: "Share conversation", group: "Conversation", icon: <Share2 />, keywords: "public link publish", run: () => { close(); openShareSheet({ conversationId: activeChatId }); } },
145 + { id: "export", label: "Export conversation", group: "Conversation", icon: <Download />, keywords: "download markdown json txt pdf print html", submode: "export" },
146 + );
147 + }
148 + return list;
149 +
150 + function copyUrl() {
151 + const url = `${window.location.origin}/app/chat/${activeChatId}`;
152 + navigator.clipboard
153 + .writeText(url)
154 + .then(() => toast.success("Conversation URL copied", url))
155 + .catch(() => toast.error("Could not copy", url));
156 + close();
41 157 }
42 − const t = setTimeout(() => {
43 − api<SearchResults>(`/api/search?q=${encodeURIComponent(q.trim())}`)
44 − .then(setResults)
45 − .catch(() => setResults(null));
46 − }, 160);
47 − return () => clearTimeout(t);
48 − }, [q]);
49 −
50 − const go = (href: string) => {
51 − setPaletteOpen(false);
52 − router.push(href);
158 + function uploadFile() {
159 + close();
160 + if (/^\/app\/chat/.test(pathname)) {
161 + window.dispatchEvent(new CustomEvent("polyllm:open-attach"));
162 + } else {
163 + router.push("/app/chat");
164 + setTimeout(() => window.dispatchEvent(new CustomEvent("polyllm:open-attach")), 450);
165 + }
166 + }
167 + function toggleSidebar() {
168 + close();
169 + if (isMobile) setSidebarOpen(true);
170 + // Desktop: the shell owns the collapsed state and listens for ⌘B on window.
171 + // TODO(integration: shell) expose `toggleSidebarCollapsed` in the store instead of a synthetic key event.
172 + else window.dispatchEvent(new KeyboardEvent("keydown", { key: "b", metaKey: true, bubbles: true }));
173 + }
174 + }, [activeChatId, close, go, isMobile, pathname, router, setSidebarOpen]);
175 +
176 + const filteredCommands = React.useMemo(() => {
177 + if (mode !== "root" || slash) return [];
178 + const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
179 + if (!tokens.length) return commands;
180 + return commands.filter((c) => {
181 + const hay = `${c.label} ${c.keywords ?? ""} ${c.group}`.toLowerCase();
182 + return tokens.every((t) => hay.includes(t));
183 + });
184 + }, [commands, mode, q, slash]);
185 +
186 + const showSearch = mode === "search" || slash || (mode === "root" && q.trim().length >= 2);
187 +
188 + /* ---- sub-lists ------------------------------------------------------------------------------ */
189 + const usableModels = React.useMemo(() => {
190 + const list = models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated");
191 + const needle = q.trim().toLowerCase();
192 + const filtered = needle ? list.filter((m) => `${m.displayName} ${m.key} ${labels[m.key] ?? ""} ${providerName(m.provider)}`.toLowerCase().includes(needle)) : list;
193 + return [...filtered].sort((a, b) => Number(favorites.has(b.key)) - Number(favorites.has(a.key)) || a.displayName.localeCompare(b.displayName)).slice(0, 40);
194 + }, [models, connectedProviders, favorites, labels, q]);
195 +
196 + const projectsQ = useApi<{ projects: Project[] }>(paletteOpen && mode === "projects" ? "/api/projects" : null, { shouldRetryOnError: false });
197 + const projectList = React.useMemo(() => {
198 + const needle = q.trim().toLowerCase();
199 + return (projectsQ.data?.projects ?? []).filter((p) => !p.archived && (!needle || p.name.toLowerCase().includes(needle)));
200 + }, [projectsQ.data, q]);
201 +
202 + const enter = (m: Mode) => {
203 + setMode(m);
204 + setQ("");
205 + inputRef.current?.focus();
53 206 };
207 + const back = () => enter("root");
208 +
209 + const onKeyDown = (e: React.KeyboardEvent) => {
210 + if (e.key === "Backspace" && q === "" && mode !== "root") {
211 + e.preventDefault();
212 + back();
213 + }
214 + };
215 +
216 + const placeholder: Record<Mode, string> = {
217 + root: "Type a command or search… (/ to search)",
218 + search: 'Search chats, messages, models… model:claude after:7d "phrase"',
219 + models: "Filter models…",
220 + projects: "Filter projects…",
221 + export: "Choose a format",
222 + theme: "Choose a theme",
223 + };
224 +
225 + const modeLabel: Partial<Record<Mode, string>> = { search: "Search", models: "Switch model", projects: "Open project", export: "Export", theme: "Theme" };
54 226
55 − const quickModels = models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated").slice(0, 8);
227 + const itemCls = cn("flex cursor-default select-none items-center gap-3 rounded-lg px-2.5 text-fg-muted transition-colors data-[selected=true]:bg-bg-muted data-[selected=true]:text-fg [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-fg-subtle", isMobile ? "min-h-[52px] py-2 text-[15px]" : "min-h-[38px] py-1.5 text-sm");
228 + const groupCls = "[&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-2.5 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-fg-subtle";
56 229
57 230 return (
58 − <Dialog open={paletteOpen} onOpenChange={setPaletteOpen}>
59 − <DialogContent size="lg" hideClose className="p-0 overflow-hidden sm:top-[18%] sm:translate-y-0">
60 − <DialogTitle className="sr-only">Command palette</DialogTitle>
61 − <Command label="Command palette" shouldFilter={!results} className="flex max-h-[70dvh] flex-col">
62 − <div className="flex items-center gap-2 border-b border-border px-3">
63 − <Search className="size-4 text-fg-subtle" />
64 − <Command.Input value={q} onValueChange={setQ} placeholder="Search chats, models, prompts… or type a command" className="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-fg-subtle" />
65 − <Kbd>esc</Kbd>
231 + <>
232 + <ResponsiveDialog
233 + open={paletteOpen}
234 + onOpenChange={setPaletteOpen}
235 + title="Command palette"
236 + hideTitle
237 + showClose={false}
238 + size="lg"
239 + snap="full"
240 + expandable={false}
241 + flush
242 + desktopFlush
243 + className={cn("overflow-hidden", !isMobile && "md:top-[14%] md:translate-y-0 md:max-h-[min(72vh,760px)]")}
244 + bodyClassName="flex min-h-0 flex-col"
245 + >
246 + <Command label="Command palette" shouldFilter={false} loop onKeyDown={onKeyDown} className="flex min-h-0 flex-1 flex-col">
247 + <div className={cn("flex items-center gap-2 border-b border-border px-3", isMobile ? "h-14" : "h-12")}>
248 + {mode !== "root" ? (
249 + <button type="button" onClick={back} className="tap -ml-1 rounded-md p-1 text-fg-muted hover:bg-bg-muted hover:text-fg" aria-label="Back to commands">
250 + <ArrowLeft className="size-4" />
251 + </button>
252 + ) : (
253 + <Search className="size-4 shrink-0 text-fg-subtle" aria-hidden />
254 + )}
255 + {mode !== "root" ? <span className="shrink-0 rounded-md bg-bg-muted px-1.5 py-0.5 text-[11px] font-medium text-fg-muted">{modeLabel[mode]}</span> : null}
256 + <Command.Input ref={inputRef} autoFocus value={q} onValueChange={setQ} placeholder={placeholder[mode]} className={cn("h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-fg-subtle", isMobile ? "text-[16px]" : "text-[15px]")} />
257 + {search.loading && showSearch ? <Spinner className="size-3.5" /> : null}
258 + {isMobile ? (
259 + <button type="button" onClick={close} className="tap rounded-md p-1 text-fg-muted" aria-label="Close">
260 + <X className="size-4" />
261 + </button>
262 + ) : (
263 + <Kbd>esc</Kbd>
264 + )}
66 265 </div>
67 − <Command.List className="min-h-0 flex-1 overflow-y-auto p-2 scrollbar-thin [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-fg-subtle">
68 − <Command.Empty className="px-3 py-8 text-center text-sm text-fg-muted">No results.</Command.Empty>
69 266
70 − {results ? (
267 + {(mode === "search" || slash) ? (
268 + <div className="border-b border-border px-3 py-2">
269 + <FilterChips query={searchQuery} onChange={(next) => { setQ(mode === "search" ? next : `/${next}`); inputRef.current?.focus(); }} compact />
270 + </div>
271 + ) : null}
272 +
273 + <Command.List className={cn("min-h-0 flex-1 overflow-y-auto p-2 scrollbar-thin contain-scroll", groupCls, isMobile ? "pb-[max(12px,var(--sab))]" : "max-h-[60vh]")}>
274 + {/* ---- root commands ---- */}
275 + {mode === "root" && !slash ? (
71 276 <>
72 − {results.conversations.length ? (
73 − <Command.Group heading="Conversations">
74 − {results.conversations.map((c) => (
75 − <Item key={c.id} onSelect={() => go(`/app/chat/${c.id}`)} icon={<MessageSquare />}>
76 − {c.title}
77 − </Item>
78 − ))}
79 − </Command.Group>
80 − ) : null}
81 − {results.messages.length ? (
82 − <Command.Group heading="Messages">
83 − {results.messages.map((m) => (
84 − <Item key={m.id} onSelect={() => go(`/app/chat/${m.conversationId}#${m.id}`)} icon={<MessageSquare />} hint={m.title}>
85 − <span className="truncate">{m.snippet}</span>
86 − </Item>
87 − ))}
88 − </Command.Group>
89 − ) : null}
90 − {results.models.length ? (
91 − <Command.Group heading="Models">
92 − {results.models.map((m) => (
93 − <Item
94 − key={m.key}
95 − onSelect={() => {
96 − setSelectedModelKey(m.key);
97 − go("/app/chat");
98 − }}
99 − icon={<ProviderIcon provider={m.provider} />}
100 − hint={m.provider}
101 − >
102 − {m.displayName}
103 − </Item>
104 − ))}
105 − </Command.Group>
106 − ) : null}
107 − {results.prompts.length ? (
108 − <Command.Group heading="Prompts">
109 − {results.prompts.map((p) => (
110 − <Item key={p.id} onSelect={() => go(`/app/prompts?edit=${p.id}`)} icon={<WandSparkles />}>
111 − {p.name}
112 − </Item>
113 − ))}
114 − </Command.Group>
115 − ) : null}
116 − {results.presets.length ? (
117 − <Command.Group heading="Presets">
118 − {results.presets.map((p) => (
119 − <Item key={p.id} onSelect={() => go(`/app/chat?preset=${p.id}`)} icon={<Sparkles />} hint={p.modelKey}>
120 − {p.name}
121 − </Item>
122 − ))}
123 − </Command.Group>
124 − ) : null}
277 + {filteredCommands.length === 0 && !showSearch ? <Command.Empty className="px-3 py-8 text-center text-sm text-fg-muted">No matching command.</Command.Empty> : null}
278 + {(["Conversation", "Actions", "Navigate", "View", "Help"] as Cmd["group"][]).map((g) => {
279 + const items = filteredCommands.filter((c) => c.group === g);
280 + if (!items.length) return null;
281 + return (
282 + <Command.Group key={g} heading={g}>
283 + {items.map((c) => (
284 + <Command.Item key={c.id} value={`cmd:${c.id}`} onSelect={() => (c.submode ? enter(c.submode) : c.run?.())} className={itemCls}>
285 + {c.icon}
286 + <span className="min-w-0 flex-1 truncate">{c.label}</span>
287 + {c.hint ? <span className="truncate text-[11px] text-fg-subtle">{c.hint}</span> : null}
288 + {c.shortcut && !isMobile ? <Kbd>{c.shortcut}</Kbd> : null}
289 + {c.submode ? <ChevronRight className="size-3.5 text-fg-subtle" /> : null}
290 + </Command.Item>
291 + ))}
292 + </Command.Group>
293 + );
294 + })}
125 295 </>
126 − ) : (
296 + ) : null}
297 +
298 + {/* ---- search results (search mode, slash, or hybrid root) ---- */}
299 + {showSearch ? (
300 + <SearchResults
301 + searchQuery={searchQuery}
302 + search={search}
303 + nav={nav}
304 + itemCls={itemCls}
305 + isMobile={isMobile}
306 + recent={recent}
307 + onPickRecent={(v) => setQ(mode === "search" ? v : `/${v}`)}
308 + hybrid={mode === "root" && !slash}
309 + />
310 + ) : null}
311 +
312 + {/* ---- switch model ---- */}
313 + {mode === "models" ? (
127 314 <>
128 − <Command.Group heading="Actions">
129 − <Item onSelect={() => go("/app/chat")} icon={<Plus />} shortcut="⌘N">
130 − New chat
131 − </Item>
132 − <Item onSelect={() => go("/app/arena")} icon={<Swords />}>
133 − Open Arena
134 − </Item>
135 − <Item onSelect={() => go("/app/models")} icon={<Boxes />}>
136 − Browse models
137 − </Item>
138 − <Item onSelect={() => go("/app/prompts")} icon={<WandSparkles />}>
139 − Prompt presets
140 − </Item>
141 − <Item onSelect={() => go("/app/presets")} icon={<Sparkles />}>
142 − Model presets
143 − </Item>
144 − <Item onSelect={() => go("/app/usage")} icon={<BarChart3 />}>
145 − Usage &amp; costs
146 − </Item>
147 − <Item onSelect={() => go("/app/settings/providers")} icon={<ShieldCheck />}>
148 − Providers &amp; API keys
149 − </Item>
150 − <Item onSelect={() => go("/app/settings/account")} icon={<Settings />}>
151 − Settings
152 − </Item>
315 + <Command.Group heading="Smart router">
316 + <Command.Item value="model:auto" onSelect={() => { setSelectedModelKey(AUTO_MODEL_KEY); window.dispatchEvent(new CustomEvent("polyllm:switch-model", { detail: { modelKey: AUTO_MODEL_KEY } })); close(); if (!activeChatId && !/^\/app\/chat/.test(pathname)) router.push("/app/chat"); }} className={itemCls}>
317 + <Wand2 />
318 + <span className="min-w-0 flex-1">
319 + <span className="block truncate text-fg">AUTO — Smart Router</span>
320 + <span className="block truncate text-[11.5px] text-fg-subtle">Picks the best connected model for each prompt</span>
321 + </span>
322 + {selectedModelKey === AUTO_MODEL_KEY ? <Check className="size-4 text-accent" /> : null}
323 + </Command.Item>
153 324 </Command.Group>
154 − {quickModels.length ? (
155 − <Command.Group heading="Switch model">
156 − {quickModels.map((m) => (
157 − <Item
158 − key={m.key}
159 − onSelect={() => {
160 − setSelectedModelKey(m.key);
161 − go("/app/chat");
162 − }}
163 − icon={<ProviderIcon provider={m.provider} />}
164 − hint={m.provider}
165 − >
166 − {m.displayName}
167 − </Item>
168 − ))}
169 − </Command.Group>
170 − ) : null}
171 − <Command.Group heading="Appearance">
172 − <Item
173 − onSelect={() => {
174 − setTheme("light");
175 − setPaletteOpen(false);
176 − }}
177 − icon={<Sun />}
178 − >
179 − Light theme
180 − </Item>
181 − <Item
182 − onSelect={() => {
183 − setTheme("dark");
184 − setPaletteOpen(false);
185 − }}
186 − icon={<Moon />}
187 − >
188 − Dark theme
189 − </Item>
190 − <Item
191 − onSelect={() => {
192 − setTheme("system");
193 − setPaletteOpen(false);
194 − }}
195 − icon={<Monitor />}
196 − >
197 − System theme
198 − </Item>
325 + <Command.Group heading={`Connected models${usableModels.length ? ` · ${usableModels.length}` : ""}`}>
326 + {usableModels.length === 0 ? <p className="px-3 py-6 text-center text-sm text-fg-muted">{models.length ? "No model matches." : "Connect a provider first (Settings → Providers)."}</p> : null}
327 + {usableModels.map((m) => (
328 + <Command.Item key={m.key} value={`model:${m.key}`} onSelect={() => { setSelectedModelKey(m.key); window.dispatchEvent(new CustomEvent("polyllm:switch-model", { detail: { modelKey: m.key } })); close(); if (!/^\/app\/chat/.test(pathname)) router.push("/app/chat"); }} className={itemCls}>
329 + <ProviderIcon provider={m.provider} size={16} />
330 + <span className="min-w-0 flex-1">
331 + <span className="block truncate text-fg">{labels[m.key] ?? m.displayName}</span>
332 + <span className="block truncate text-[11.5px] text-fg-subtle">
333 + {providerName(m.provider)}
334 + {labels[m.key] ? ` · ${m.displayName}` : ""}
335 + {favorites.has(m.key) ? " · ★" : ""}
336 + </span>
337 + </span>
338 + {selectedModelKey === m.key ? <Check className="size-4 text-accent" /> : null}
339 + </Command.Item>
340 + ))}
199 341 </Command.Group>
200 342 </>
201 − )}
343 + ) : null}
344 +
345 + {/* ---- open project ---- */}
346 + {mode === "projects" ? (
347 + <Command.Group heading="Projects">
348 + {projectsQ.isLoading ? <p className="px-3 py-6 text-center text-sm text-fg-muted">Loading projects…</p> : null}
349 + {projectsQ.error ? <p className="px-3 py-6 text-center text-sm text-fg-muted">Projects are not available yet.</p> : null}
350 + {!projectsQ.isLoading && !projectsQ.error && projectList.length === 0 ? (
351 + <Command.Item value="project:new" onSelect={() => go("/app/projects?new=1")} className={itemCls}>
352 + <Plus />
353 + <span className="flex-1">Create your first project</span>
354 + </Command.Item>
355 + ) : null}
356 + {projectList.map((p) => (
357 + <Command.Item key={p.id} value={`project:${p.id}`} onSelect={() => { setActiveProjectId(p.id); go(`/app/projects/${p.id}`); }} className={itemCls}>
358 + <span className="flex size-4 items-center justify-center text-[13px]" style={p.color ? { color: p.color } : undefined} aria-hidden>
359 + {p.icon ?? <FolderKanban className="size-4" />}
360 + </span>
361 + <span className="min-w-0 flex-1">
362 + <span className="block truncate text-fg">{p.name}</span>
363 + {p.description ? <span className="block truncate text-[11.5px] text-fg-subtle">{p.description}</span> : null}
364 + </span>
365 + {activeProjectId === p.id ? <Check className="size-4 text-accent" /> : null}
366 + </Command.Item>
367 + ))}
368 + </Command.Group>
369 + ) : null}
370 +
371 + {/* ---- export ---- */}
372 + {mode === "export" && activeChatId ? (
373 + <Command.Group heading="Export this conversation">
374 + {EXPORT_OPTIONS.map((o) => (
375 + <Command.Item key={o.format} value={`export:${o.format}`} onSelect={() => { close(); void exportConversation(activeChatId, o.format).catch(() => {}); }} className={itemCls}>
376 + {EXPORT_ICONS[o.format]}
377 + <span className="min-w-0 flex-1">
378 + <span className="block truncate text-fg">{o.label}</span>
379 + <span className="block truncate text-[11.5px] text-fg-subtle">{o.description}</span>
380 + </span>
381 + <span className="font-mono text-[11px] text-fg-subtle">{o.hint}</span>
382 + </Command.Item>
383 + ))}
384 + </Command.Group>
385 + ) : null}
386 +
387 + {/* ---- theme ---- */}
388 + {mode === "theme" ? (
389 + <Command.Group heading="Appearance">
390 + {(
391 + [
392 + { v: "light", label: "Light", icon: <Sun /> },
393 + { v: "dark", label: "Dark", icon: <Moon /> },
394 + { v: "system", label: "System", icon: <Monitor /> },
395 + ] as { v: string; label: string; icon: React.ReactNode }[]
396 + ).map((t) => (
397 + <Command.Item key={t.v} value={`theme:${t.v}`} onSelect={() => { setTheme(t.v); close(); }} className={itemCls}>
398 + {t.icon}
399 + <span className="flex-1">{t.label}</span>
400 + {theme === t.v ? <Check className="size-4 text-accent" /> : null}
401 + </Command.Item>
402 + ))}
403 + </Command.Group>
404 + ) : null}
202 405 </Command.List>
406 +
407 + {!isMobile ? (
408 + <div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-[11px] text-fg-subtle">
409 + <span className="inline-flex items-center gap-1">
410 + <Kbd>↑↓</Kbd> navigate
411 + </span>
412 + <span className="inline-flex items-center gap-1">
413 + <Kbd>↵</Kbd> select
414 + </span>
415 + {mode !== "root" ? (
416 + <span className="inline-flex items-center gap-1">
417 + <Kbd>⌫</Kbd> back
418 + </span>
419 + ) : (
420 + <span className="inline-flex items-center gap-1">
421 + <Kbd>/</Kbd> search
422 + </span>
423 + )}
424 + <span className="ml-auto inline-flex items-center gap-1">
425 + <Kbd>esc</Kbd> close
426 + </span>
427 + </div>
428 + ) : null}
203 429 </Command>
204 − </DialogContent>
205 − </Dialog>
430 + </ResponsiveDialog>
431 +
432 + <ShortcutsSheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
433 + </>
434 + );
435 +}
436 +
437 +/* ------------------------------------------------------------------------------------------------
438 + * Search results inside cmdk (↑↓↵ navigation)
439 + * ---------------------------------------------------------------------------------------------- */
440 +function SearchResults({ searchQuery, search, nav, itemCls, isMobile, recent, onPickRecent, hybrid }: { searchQuery: string; search: ReturnType<typeof useSearch>; nav: ReturnType<typeof useSearchNavigation>; itemCls: string; isMobile: boolean; recent: ReturnType<typeof useRecentSearches>; onPickRecent: (v: string) => void; hybrid: boolean }) {
441 + const data = search.data;
442 + if (!search.searchable) {
443 + if (hybrid) return null;
444 + return (
445 + <>
446 + {recent.list.length ? (
447 + <Command.Group heading="Recent searches">
448 + {recent.list.map((r) => (
449 + <Command.Item key={r} value={`recent:${r}`} onSelect={() => onPickRecent(r)} className={itemCls}>
450 + <Clock />
451 + <span className="min-w-0 flex-1 truncate">{r}</span>
452 + <button type="button" onClick={(e) => { e.stopPropagation(); recent.remove(r); }} className="tap rounded p-1 text-fg-subtle hover:text-fg" aria-label={`Remove “${r}”`}>
453 + <X className="size-3.5" />
454 + </button>
455 + </Command.Item>
456 + ))}
457 + </Command.Group>
458 + ) : null}
459 + <Command.Group heading="Search syntax">
460 + {SEARCH_EXAMPLES.map((e) => (
461 + <Command.Item key={e.label} value={`example:${e.label}`} onSelect={() => onPickRecent(e.q)} className={itemCls}>
462 + <Search />
463 + <span className="flex-1 font-mono text-[12.5px]">{e.label}</span>
464 + </Command.Item>
465 + ))}
466 + <p className="px-2.5 py-2 text-[11.5px] leading-5 text-fg-subtle">
467 + Free text + <code className="font-mono">model:</code> <code className="font-mono">provider:</code> <code className="font-mono">project:</code> <code className="font-mono">folder:</code> <code className="font-mono">after:</code> <code className="font-mono">before:</code> <code className="font-mono">role:</code> <code className="font-mono">is:</code> · quote phrases with &quot;…&quot;
468 + </p>
469 + </Command.Group>
470 + </>
471 + );
472 + }
473 + if (search.loading && !data) {
474 + return <p className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-fg-muted"><Spinner /> Searching…</p>;
475 + }
476 + if (search.error) return <p className="px-3 py-8 text-center text-sm text-fg-muted">Search is unavailable right now.</p>;
477 + if (!data) return null;
478 + if (search.isEmpty) {
479 + return (
480 + <div className="flex flex-col items-center px-3 py-8 text-center">
481 + <SearchX className="size-5 text-fg-subtle" />
482 + <p className="mt-2 text-sm font-medium">No results for “{searchQuery.trim()}”</p>
483 + <p className="mt-0.5 text-[12.5px] text-fg-muted">Try fewer words or remove a filter.</p>
484 + </div>
485 + );
486 + }
487 + const terms = data.query.terms;
488 + const dense = !isMobile;
489 + const groups = GROUP_ORDER.filter((g: SearchGroup) => (data[g] as unknown[]).length > 0);
490 + return (
491 + <>
492 + {groups.map((g) => (
493 + <Command.Group key={g} heading={`${GROUP_LABELS[g]} · ${(data[g] as unknown[]).length}`}>
494 + {g === "conversations" &&
495 + data.conversations.map((c) => (
496 + <Command.Item key={c.id} value={`conv:${c.id}`} onSelect={() => nav.openConversation(c)} className={itemCls}>
497 + <ConversationHitContent hit={c} terms={terms} dense={dense} />
498 + </Command.Item>
499 + ))}
500 + {g === "messages" &&
501 + data.messages.map((m) => (
502 + <Command.Item key={m.id} value={`msg:${m.id}`} onSelect={() => nav.openMessage(m)} className={cn(itemCls, "items-start")}>
503 + <MessageHitContent hit={m} terms={terms} dense={dense} />
504 + </Command.Item>
505 + ))}
506 + {g === "models" &&
507 + data.models.map((m) => (
508 + <Command.Item key={m.key} value={`smodel:${m.key}`} onSelect={() => nav.openModel(m)} className={itemCls}>
509 + <ModelHitContent hit={m} dense={dense} />
510 + </Command.Item>
511 + ))}
512 + {g === "prompts" &&
513 + data.prompts.map((p) => (
514 + <Command.Item key={`${p.kind}-${p.id}`} value={`prompt:${p.kind}:${p.id}`} onSelect={() => nav.openPrompt(p)} className={itemCls}>
515 + <PromptHitContent hit={p} terms={terms} dense={dense} />
516 + </Command.Item>
517 + ))}
518 + {g === "projects" &&
519 + data.projects.map((p) => (
520 + <Command.Item key={p.id} value={`sproject:${p.id}`} onSelect={() => nav.openProject(p)} className={itemCls}>
521 + <ProjectHitContent hit={p} terms={terms} dense={dense} />
522 + </Command.Item>
523 + ))}
524 + {g === "presets" &&
525 + data.presets.map((p) => (
526 + <Command.Item key={p.id} value={`preset:${p.id}`} onSelect={() => nav.openPreset(p)} className={itemCls}>
527 + <PresetHitContent hit={p} terms={terms} dense={dense} />
528 + </Command.Item>
529 + ))}
530 + </Command.Group>
531 + ))}
532 + {search.nextCursor ? (
533 + <Command.Group>
534 + <Command.Item value="search:more" onSelect={() => void search.loadMore()} className={cn(itemCls, "justify-center text-accent data-[selected=true]:text-accent")}>
535 + {search.loadingMore ? <Spinner className="size-3.5" /> : null}
536 + <span>Load more results</span>
537 + </Command.Item>
538 + </Command.Group>
539 + ) : null}
540 + <p className="px-2.5 pt-2 text-right text-[10.5px] tabular-nums text-fg-subtle">
541 + {search.total} result{search.total === 1 ? "" : "s"} · {data.tookMs} ms
542 + </p>
543 + </>
206 544 );
207 545 }
208 546
209 −function Item({ children, onSelect, icon, hint, shortcut }: { children: React.ReactNode; onSelect: () => void; icon?: React.ReactNode; hint?: string; shortcut?: string }) {
547 +/* ------------------------------------------------------------------------------------------------
548 + * Keyboard shortcuts help
549 + * ---------------------------------------------------------------------------------------------- */
550 +const SHORTCUTS: { keys: string[]; label: string; scope: string }[] = [
551 + { keys: ["⌘", "K"], label: "Command palette / search", scope: "Global" },
552 + { keys: ["⌘", "N"], label: "New chat", scope: "Global" },
553 + { keys: ["⌘", "B"], label: "Toggle sidebar", scope: "Global" },
554 + { keys: ["⌘", "/"], label: "Focus the composer", scope: "Chat" },
555 + { keys: ["?"], label: "Open this help (outside inputs)", scope: "Global" },
556 + { keys: ["/"], label: "Search mode inside the palette", scope: "Palette" },
557 + { keys: ["↑", "↓"], label: "Move selection", scope: "Palette" },
558 + { keys: ["↵"], label: "Send message / select item", scope: "Chat · Palette" },
559 + { keys: ["⇧", "↵"], label: "New line in the composer", scope: "Chat" },
560 + { keys: ["Esc"], label: "Stop generation / close sheets and dialogs", scope: "Chat · Global" },
561 + { keys: ["⌫"], label: "Back to commands (empty input)", scope: "Palette" },
562 +];
563 +
564 +export function ShortcutsSheet({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
565 + const isMobile = useIsMobile();
210 566 return (
211 − <Command.Item onSelect={onSelect} className="flex cursor-default select-none items-center gap-2.5 rounded-md px-2 py-2 text-sm text-fg-muted data-[selected=true]:bg-bg-muted data-[selected=true]:text-fg [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-fg-subtle">
212 − {icon}
213 − <span className="min-w-0 flex-1 truncate">{children}</span>
214 − {hint ? <span className="truncate text-[11px] text-fg-subtle max-w-[35%]">{hint}</span> : null}
215 − {shortcut ? <Kbd>{shortcut}</Kbd> : null}
216 − </Command.Item>
567 + <ResponsiveDialog open={open} onOpenChange={onOpenChange} title="Keyboard shortcuts" description={isMobile ? "Available when a keyboard is connected." : "Press ? anywhere to open this list."} size="sm">
568 + <ul className="divide-y divide-hairline">
569 + {SHORTCUTS.map((s) => (
570 + <li key={s.label} className="flex min-h-[44px] items-center gap-3 py-2">
571 + <span className="min-w-0 flex-1">
572 + <span className="block text-[14px] text-fg">{s.label}</span>
573 + <span className="block text-[11.5px] text-fg-subtle">{s.scope}</span>
574 + </span>
575 + <span className="flex shrink-0 items-center gap-1">
576 + {s.keys.map((k) => (
577 + <Kbd key={k} className="h-6 min-w-[1.6rem] text-[11px]">
578 + {k}
579 + </Kbd>
580 + ))}
581 + </span>
582 + </li>
583 + ))}
584 + </ul>
585 + </ResponsiveDialog>
217 586 );
218 587 }
modified src/components/app/onboarding.tsx +48 −96
@@ -1,111 +1,63 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { Check, ExternalLink, KeyRound } from "lucide-react";
4 −import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter } from "@/components/ui/dialog";
5 −import { Button } from "@/components/ui/button";
6 −import { Badge } from "@/components/ui/badge";
7 −import { LogoMark } from "@/components/brand/logo";
8 −import { ProviderIcon } from "@/components/brand/provider-icon";
9 −import { AddKeyDialog, useAddKeyDialog } from "@/components/providers/add-key-dialog";
3 +import Link from "next/link";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { ArrowRight, Sparkles } from "lucide-react";
10 6 import { useApp } from "@/components/app/store";
11 −import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers";
12 −import { toast } from "@/components/ui/toast";
13 −import { errorMessage } from "@/lib/client/humanize";
7 +import { useApi } from "@/lib/client/api";
8 +import type { PublicConnection } from "@/lib/client/types";
9 +
10 +const SS_REDIRECTED = "polyllm:onboarding-redirected";
11 +const LS_DONE_PREFIX = "polyllm:onboarding-done:";
14 12
15 13 /**
16 − * First-visit onboarding: shown once when the user has never completed onboarding and has no
17 − * provider connected yet. Mount it anywhere inside <AppProvider>.
14 + * First-run gate. Mounted by the chat empty state (and safe anywhere inside <AppProvider>).
15 + *
16 + * A user who has never completed onboarding and has no provider connected is sent to
17 + * `/app/onboarding` once per browser session; afterwards this renders a small "Finish setup" link
18 + * so nobody is trapped in a redirect loop. Completion is stored server-side
19 + * (`users.onboarding_completed_at`) by the onboarding page.
18 20 */
19 21 export function Onboarding() {
20 − const { user, connections, updatePreferences } = useApp();
21 − const [dismissed, setDismissed] = React.useState(false);
22 − const [finishing, setFinishing] = React.useState(false);
23 − const keyDialog = useAddKeyDialog();
22 + const { user } = useApp();
23 + const router = useRouter();
24 + const pathname = usePathname();
25 + // Same SWR key as the store → deduped, but gives us `isLoading` so we never redirect on an empty first render.
26 + const providers = useApi<{ connections: PublicConnection[] }>("/api/providers");
27 + const [redirected, setRedirected] = React.useState<boolean | null>(null);
24 28
25 − const shouldShow = !dismissed && user.onboardingCompletedAt === null && connections.length === 0;
26 − const connected = React.useMemo(() => new Set(connections.filter((c) => c.status !== "invalid").map((c) => c.provider)), [connections]);
29 + const eligible = user.onboardingCompletedAt === null && !providers.isLoading && (providers.data?.connections.length ?? 0) === 0;
27 30
28 − const finish = async (label: string) => {
29 − setFinishing(true);
31 + React.useEffect(() => {
32 + if (!eligible || pathname.startsWith("/app/onboarding")) return;
33 + let doneLocally = false;
34 + let already = false;
30 35 try {
31 − await updatePreferences({ onboardingCompleted: true });
32 − setDismissed(true);
33 − if (label) toast.info(label);
34 − } catch (e) {
35 − toast.error("Could not save", errorMessage(e));
36 − } finally {
37 − setFinishing(false);
36 + doneLocally = window.localStorage.getItem(LS_DONE_PREFIX + user.id) === "1";
37 + already = window.sessionStorage.getItem(SS_REDIRECTED) === "1";
38 + } catch {
39 + /* storage unavailable */
38 40 }
39 − };
40 −
41 − // Once a key is connected, the dialog closes itself via `connections.length === 0` becoming false;
42 − // mark onboarding done so it never comes back.
43 − const onSaved = () => {
44 − void updatePreferences({ onboardingCompleted: true }).catch(() => {});
45 − };
41 + if (doneLocally) return;
42 + if (already) {
43 + // eslint-disable-next-line react-hooks/set-state-in-effect
44 + setRedirected(true);
45 + return;
46 + }
47 + try {
48 + window.sessionStorage.setItem(SS_REDIRECTED, "1");
49 + } catch {
50 + /* ignore */
51 + }
52 + router.replace("/app/onboarding");
53 + }, [eligible, pathname, router, user.id]);
46 54
55 + if (!eligible || !redirected) return null;
47 56 return (
48 − <>
49 − <Dialog open={shouldShow && !keyDialog.props.open} onOpenChange={(v) => (!v ? void finish("You can connect providers anytime in Settings → Providers.") : null)}>
50 − <DialogContent size="lg" hideClose>
51 − <DialogHeader className="items-start">
52 − <div className="flex items-center gap-3">
53 − <LogoMark size={36} />
54 − <div>
55 − <DialogTitle className="text-lg">Welcome to PolyLLM</DialogTitle>
56 − <DialogDescription>Connect your AI providers to start chatting. Your models, your keys, one workspace.</DialogDescription>
57 − </div>
58 − </div>
59 − </DialogHeader>
60 − <DialogBody className="space-y-4">
61 − <ul className="grid gap-2.5 sm:grid-cols-2">
62 − {PROVIDER_ORDER.map((p) => {
63 − const meta = PROVIDERS[p];
64 − const done = connected.has(p);
65 − return (
66 − <li key={p} className="flex flex-col justify-between gap-3 rounded-xl border border-border bg-bg-subtle/60 p-4 transition-colors hover:border-border-strong">
67 − <div className="flex items-start gap-3">
68 − <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated">
69 − <ProviderIcon provider={p} size={18} />
70 − </span>
71 − <div className="min-w-0">
72 − <div className="flex items-center gap-2">
73 − <p className="text-sm font-semibold leading-5">{meta.name}</p>
74 − {done ? (
75 − <Badge variant="success">
76 − <Check /> Connected
77 − </Badge>
78 − ) : null}
79 − </div>
80 − <p className="mt-0.5 text-xs leading-4 text-fg-muted">{meta.description}</p>
81 − </div>
82 − </div>
83 − <div className="flex items-center justify-between gap-2">
84 − <a href={meta.keyDocsUrl} target="_blank" rel="noreferrer noopener" className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-accent">
85 − Get a key <ExternalLink className="size-3" />
86 − </a>
87 − <Button size="sm" variant={done ? "outline" : "primary"} onClick={() => keyDialog.open(p, done)}>
88 − <KeyRound />
89 − {done ? "Replace" : "Connect"}
90 − </Button>
91 − </div>
92 − </li>
93 − );
94 − })}
95 − </ul>
96 − <p className="text-xs text-fg-subtle">
97 − PolyLLM never asks for your provider account password — only an API key, which is validated once and stored encrypted. Requests go straight from our server to the provider with your key; nothing is shared with third parties.
98 − </p>
99 − </DialogBody>
100 − <DialogFooter className="sm:justify-between">
101 − <span className="hidden text-xs text-fg-subtle sm:inline">You can add or remove keys anytime in Settings → Providers.</span>
102 − <Button variant="ghost" loading={finishing} onClick={() => finish("Skipped — connect providers anytime in Settings.")}>
103 − Skip for now
104 − </Button>
105 − </DialogFooter>
106 − </DialogContent>
107 − </Dialog>
108 − <AddKeyDialog {...keyDialog.props} onSaved={onSaved} />
109 − </>
57 + <Link href="/app/onboarding" className="mb-5 inline-flex min-h-[40px] items-center gap-2 rounded-full bg-accent-soft px-3.5 text-[13px] font-medium text-accent transition-colors hover:bg-accent/20">
58 + <Sparkles className="size-3.5" aria-hidden />
59 + Finish setting up PolyLLM
60 + <ArrowRight className="size-3.5" aria-hidden />
61 + </Link>
110 62 );
111 63 }
modified src/components/arena/arena-column.tsx +63 −102
@@ -1,39 +1,48 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { Brain, Check, ChevronDown, ChevronRight, Copy, ExternalLink, Globe, Loader2, Trophy, Wrench, X, Zap, Coins } from "lucide-react";
3 +import { Brain, Check, ChevronDown, ChevronRight, Coins, Copy, ExternalLink, Eye, Globe, Loader2, Trophy, Wrench, X, Zap } from "lucide-react";
4 4 import type { PolyModel } from "@/lib/client/types";
5 5 import { Markdown } from "@/components/markdown/markdown";
6 6 import { ProviderIcon } from "@/components/brand/provider-icon";
7 7 import { Badge } from "@/components/ui/badge";
8 8 import { Tooltip } from "@/components/ui/tooltip";
9 9 import { PROVIDERS } from "@/lib/client/providers";
10 −import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";
11 −import { RATINGS, metricsOf, isFinal, type ColumnState, type ColumnStatus } from "./types";
10 +import { liveMetrics } from "@/lib/arena/metrics";
11 +import { blindLabel, type Criterion } from "@/lib/arena/scoring";
12 +import { cn, formatMs, formatTokens } from "@/lib/utils";
13 +import { isFinal, type ColumnState } from "./types";
14 +import { MetricsStrip, STATUS_META, StatusDot } from "./metrics-strip";
15 +import { VotePanel } from "./vote-panel";
16 +import { BlindAvatar, Flip } from "./blind";
12 17
13 −export const STATUS_META: Record<ColumnStatus, { label: string; dot: string; pulse: boolean }> = {
14 − idle: { label: "Ready", dot: "bg-fg-subtle", pulse: false },
15 − waiting: { label: "Waiting", dot: "bg-fg-subtle", pulse: true },
16 − thinking: { label: "Thinking", dot: "bg-accent", pulse: true },
17 − streaming: { label: "Streaming", dot: "bg-info", pulse: true },
18 − done: { label: "Done", dot: "bg-success", pulse: false },
19 − error: { label: "Failed", dot: "bg-danger", pulse: false },
20 − stopped: { label: "Stopped", dot: "bg-warning", pulse: false },
21 −};
22 −
23 −export function StatusDot({ status, className }: { status: ColumnStatus; className?: string }) {
24 − const s = STATUS_META[status];
25 − return <span className={cn("inline-block size-2 shrink-0 rounded-full", s.dot, s.pulse && "animate-pulse-soft", className)} aria-hidden />;
26 −}
18 +export { STATUS_META, StatusDot };
27 19
28 20 interface Props {
29 21 column: ColumnState;
30 22 model?: PolyModel;
23 + /** Display position (0-based) — drives the Blind Arena letter. */
24 + index: number;
25 + /** Blind Arena with identity still hidden. */
26 + hidden?: boolean;
27 + /** Reveal flip in progress. */
28 + flipping?: boolean;
29 + onReveal?: () => void;
31 30 winners: { fastest: string | null; cheapest: string | null };
31 + isWinner?: boolean;
32 + criteria: Criterion[];
33 + /** Criterion ids this response has won. */
34 + won: ReadonlySet<string>;
35 + onVote: (responseId: string, criterionId: string, on: boolean) => void;
36 + onAddCriterion?: (label: string) => void;
37 + onRemoveCriterion?: (id: string) => void;
38 + voteBusy?: boolean;
39 + /** Prompt (+ system prompt, attachments) token estimate for live cost. */
40 + inputTokens: number;
41 + /** Shared clock from the parent (`useTick`). */
42 + now: number;
32 43 wrapCode?: boolean;
33 44 showReasoning?: boolean;
34 45 showCosts?: boolean;
35 − ratingBusy?: boolean;
36 − onRate: (responseId: string, key: string, value: boolean) => void;
37 46 /** Sync-scroll plumbing (optional). */
38 47 bodyRef?: (el: HTMLDivElement | null) => void;
39 48 onBodyScroll?: (el: HTMLDivElement) => void;
@@ -41,14 +50,13 @@ interface Props {
41 50 style?: React.CSSProperties;
42 51 }
43 52
44 −export const ArenaColumn = React.memo(function ArenaColumn({ column: c, model, winners, wrapCode, showReasoning = true, showCosts = true, ratingBusy, onRate, bodyRef, onBodyScroll, className, style }: Props) {
53 +export const ArenaColumn = React.memo(function ArenaColumn({ column: c, model, index, hidden, flipping, onReveal, winners, isWinner, criteria, won, onVote, onAddCriterion, onRemoveCriterion, voteBusy, inputTokens, now, wrapCode, showReasoning = true, showCosts = true, bodyRef, onBodyScroll, className, style }: Props) {
45 54 const provider = model?.provider ?? c.response?.provider ?? c.modelKey.split("/")[0];
46 − const name = model?.displayName ?? c.modelKey.split("/").slice(1).join("/");
55 + const realName = model?.displayName ?? c.modelKey.split("/").slice(1).join("/");
56 + const name = hidden ? blindLabel(index) : realName;
47 57 const live = c.status === "waiting" || c.status === "thinking" || c.status === "streaming";
48 58 const final = isFinal(c.status);
49 − const metrics = metricsOf(c.response);
50 − const ratings = c.response?.ratings ?? {};
51 − const isPick = Boolean(ratings.best);
59 + const metrics = React.useMemo(() => liveMetrics(c, model, inputTokens, now), [c, model, inputTokens, now]);
52 60 const [copied, setCopied] = React.useState(false);
53 61
54 62 // Keep the body pinned to the bottom while streaming unless the user scrolled up.
@@ -66,50 +74,58 @@ export const ArenaColumn = React.memo(function ArenaColumn({ column: c, model, w
66 74 };
67 75
68 76 const status = STATUS_META[c.status];
69 − const elapsed = live ? <LiveTimer since={c.startedAt} /> : null;
70 77
71 78 return (
72 − <section className={cn("flex min-w-0 flex-col overflow-hidden rounded-xl border bg-bg-elevated transition-[border-color,box-shadow]", isPick ? "border-accent/60 shadow-glow" : "border-border", className)} style={style} aria-label={`${name} response`}>
79 + <section className={cn("flex min-w-0 flex-col overflow-hidden rounded-xl border bg-bg-elevated transition-[border-color,box-shadow]", isWinner ? "border-accent/60 shadow-glow" : "border-border", className)} style={style} aria-label={`${name} response`}>
73 80 {/* Header */}
74 81 <header className="flex items-center gap-2 border-b border-border px-3 py-2">
75 − <span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-bg-subtle">
76 − <ProviderIcon provider={provider} size={15} />
77 − </span>
82 + <Flip flipping={Boolean(flipping)}>
83 + <span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-bg-subtle">{hidden ? <BlindAvatar index={index} size={18} /> : <ProviderIcon provider={provider} size={15} />}</span>
84 + </Flip>
78 85 <div className="min-w-0 flex-1">
79 − <div className="flex items-center gap-1.5">
80 − <span className="truncate text-[13px] font-semibold leading-5">{name}</span>
81 − {model?.capabilities.reasoning ? <Brain className="size-3.5 shrink-0 text-fg-subtle" aria-label="Reasoning model" /> : null}
82 − </div>
86 + <Flip flipping={Boolean(flipping)} className="flex w-full">
87 + <div className="flex min-w-0 items-center gap-1.5">
88 + <span className="truncate text-[13px] font-semibold leading-5">{name}</span>
89 + {!hidden && model?.capabilities.reasoning ? <Brain className="size-3.5 shrink-0 text-fg-subtle" aria-label="Reasoning model" /> : null}
90 + </div>
91 + </Flip>
83 92 <div className="flex items-center gap-1.5 text-[11.5px] text-fg-muted">
84 93 <StatusDot status={c.status} />
85 94 <span>{status.label}</span>
86 − {elapsed}
87 − <span className="text-fg-subtle">· {PROVIDERS[provider as keyof typeof PROVIDERS]?.shortName ?? provider}</span>
95 + {live ? <span className="tabular-nums text-fg-subtle">· {(metrics.elapsedMs / 1000).toFixed(1)} s</span> : null}
96 + {!hidden ? <span className="truncate text-fg-subtle">· {PROVIDERS[provider as keyof typeof PROVIDERS]?.shortName ?? provider}</span> : <span className="text-fg-subtle">· identity hidden</span>}
88 97 </div>
89 98 </div>
90 99 <div className="flex shrink-0 items-center gap-1">
91 − {isPick ? (
100 + {isWinner ? (
92 101 <Badge variant="accent" className="gap-1">
93 − <Trophy /> Your pick
102 + <Trophy /> Winner
94 103 </Badge>
95 104 ) : null}
96 105 {winners.fastest === c.modelKey ? (
97 106 <Tooltip content="Fastest time to first token">
98 − <Badge variant="success" className="gap-1">
107 + <Badge variant="success" className="hidden gap-1 sm:inline-flex">
99 108 <Zap /> Fastest
100 109 </Badge>
101 110 </Tooltip>
102 111 ) : null}
103 112 {winners.cheapest === c.modelKey && showCosts ? (
104 113 <Tooltip content="Lowest estimated cost">
105 − <Badge variant="info" className="gap-1">
114 + <Badge variant="info" className="hidden gap-1 sm:inline-flex">
106 115 <Coins /> Cheapest
107 116 </Badge>
108 117 </Tooltip>
109 118 ) : null}
119 + {hidden && onReveal && final ? (
120 + <Tooltip content="Reveal this model">
121 + <button onClick={onReveal} className="tap rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label="Reveal model identity">
122 + <Eye />
123 + </button>
124 + </Tooltip>
125 + ) : null}
110 126 {c.text ? (
111 127 <Tooltip content={copied ? "Copied" : "Copy response"}>
112 − <button onClick={copy} className="rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label="Copy response">
128 + <button onClick={copy} className="tap rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label="Copy response">
113 129 {copied ? <Check className="text-success" /> : <Copy />}
114 130 </button>
115 131 </Tooltip>
@@ -128,7 +144,7 @@ export const ArenaColumn = React.memo(function ArenaColumn({ column: c, model, w
128 144 pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
129 145 onBodyScroll?.(el);
130 146 }}
131 − className="min-h-[160px] max-h-[min(62vh,720px)] flex-1 space-y-2.5 overflow-y-auto overscroll-contain px-3.5 py-3 scrollbar-thin"
147 + className="min-h-[140px] max-h-[min(58dvh,720px)] flex-1 space-y-2.5 overflow-y-auto overscroll-contain px-3.5 py-3 scrollbar-thin md:max-h-[min(62vh,720px)]"
132 148 >
133 149 {(c.reasoning || c.status === "thinking") && showReasoning ? <ReasoningBlock text={c.reasoning} streaming={live && !c.text} /> : null}
134 150
@@ -170,73 +186,18 @@ export const ArenaColumn = React.memo(function ArenaColumn({ column: c, model, w
170 186 {c.citations.length ? <Citations items={c.citations} /> : null}
171 187 </div>
172 188
173 − {/* Footer: metrics + ratings */}
174 − {final ? (
189 + {/* Footer: live metrics + votes */}
190 + {c.status !== "idle" ? (
175 191 <footer className="border-t border-border bg-bg-subtle/50 px-3 py-2">
176 − {metrics ? (
177 − <dl className="grid grid-cols-3 gap-x-3 gap-y-1.5 text-[11.5px] tabular-nums sm:grid-cols-6">
178 − <Metric label="TTFT" value={formatMs(metrics.ttftMs)} highlight={winners.fastest === c.modelKey} />
179 − <Metric label="Total" value={formatMs(metrics.latencyMs)} />
180 − <Metric label="Tokens" value={metrics.inputTokens === null && metrics.outputTokens === null ? "—" : `${formatTokens(metrics.inputTokens ?? 0)} → ${formatTokens(metrics.outputTokens ?? 0)}`} hint={metrics.reasoningTokens ? `+${formatTokens(metrics.reasoningTokens)} reasoning` : metrics.cachedTokens ? `${formatTokens(metrics.cachedTokens)} cached` : undefined} />
181 − <Metric label="Speed" value={metrics.tokensPerSecond ? `${metrics.tokensPerSecond} tok/s` : "—"} />
182 − {showCosts ? <Metric label="Est. cost" value={metrics.costUsd === null ? "—" : `≈ ${formatUsd(metrics.costUsd, { precise: metrics.costUsd < 0.01 })}`} highlight={winners.cheapest === c.modelKey} title="Estimated from the provider's list price" /> : <Metric label="Finish" value={statusWord(c)} />}
183 − {showCosts ? <Metric label="Finish" value={statusWord(c)} /> : null}
184 − </dl>
185 − ) : (
186 − <p className="text-[11.5px] text-fg-subtle">No metrics recorded.</p>
187 − )}
188 − {c.responseId ? (
189 − <div className="mt-2 flex flex-wrap items-center gap-1" role="group" aria-label="Rate this response">
190 − {RATINGS.map((r) => {
191 − const on = Boolean(ratings[r.key]);
192 − return (
193 − <Tooltip key={r.key} content={r.label}>
194 − <button
195 − type="button"
196 − disabled={ratingBusy}
197 − aria-pressed={on}
198 − onClick={() => onRate(c.responseId!, r.key, !on)}
199 − className={cn("inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11.5px] font-medium transition-colors disabled:opacity-50", on ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-border-strong hover:text-fg")}
200 − >
201 − {r.key === "best" ? <Trophy className="size-3" /> : null}
202 − {r.short}
203 − </button>
204 − </Tooltip>
205 − );
206 − })}
207 − </div>
208 − ) : null}
192 + <MetricsStrip metrics={metrics} showCosts={showCosts} fastest={winners.fastest === c.modelKey} cheapest={winners.cheapest === c.modelKey} />
193 + {final && c.responseId ? <VotePanel className="mt-2" criteria={criteria} won={won} busy={voteBusy} onToggle={(id, on) => onVote(c.responseId!, id, on)} onAddCriterion={onAddCriterion} onRemoveCriterion={onRemoveCriterion} /> : null}
194 + {final && c.status === "error" && !c.responseId ? <p className="mt-1.5 text-[11.5px] text-fg-subtle">Latency {formatMs(metrics.elapsedMs)}</p> : null}
209 195 </footer>
210 196 ) : null}
211 197 </section>
212 198 );
213 199 });
214 200
215 −function statusWord(c: ColumnState): string {
216 − if (c.status === "done") return "complete";
217 − if (c.status === "stopped") return "stopped";
218 − return c.error?.code ?? "error";
219 −}
220 −
221 −function Metric({ label, value, hint, highlight, title }: { label: string; value: string; hint?: string; highlight?: boolean; title?: string }) {
222 − return (
223 − <div className="min-w-0" title={title}>
224 − <dt className="text-[10.5px] uppercase tracking-wide text-fg-subtle">{label}</dt>
225 − <dd className={cn("truncate font-medium", highlight ? "text-success" : "text-fg")}>{value}</dd>
226 − {hint ? <dd className="truncate text-[10.5px] text-fg-subtle">{hint}</dd> : null}
227 − </div>
228 − );
229 −}
230 −
231 −function LiveTimer({ since }: { since: number }) {
232 − const [now, setNow] = React.useState(() => Date.now());
233 − React.useEffect(() => {
234 − const t = setInterval(() => setNow(Date.now()), 250);
235 − return () => clearInterval(t);
236 − }, []);
237 − return <span className="tabular-nums text-fg-subtle">· {((now - since) / 1000).toFixed(1)} s</span>;
238 −}
239 −
240 201 function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
241 202 const [open, setOpen] = React.useState(streaming);
242 203 React.useEffect(() => {
@@ -252,7 +213,7 @@ function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean
252 213 }, [text, open, streaming]);
253 214 return (
254 215 <div className="rounded-lg border border-border bg-bg-subtle/60">
255 − <button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-fg-muted hover:text-fg" aria-expanded={open}>
216 + <button onClick={() => setOpen((o) => !o)} className="flex min-h-9 w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-fg-muted hover:text-fg" aria-expanded={open}>
256 217 <Brain className={cn("size-3.5", streaming && "animate-pulse-soft text-accent")} />
257 218 <span className="font-medium">{streaming ? "Thinking…" : "Reasoning"}</span>
258 219 {!streaming && text ? <span className="text-fg-subtle">· ≈ {formatTokens(Math.round(text.length / 4))} tok</span> : null}
modified src/components/arena/arena-history.tsx +186 −61
@@ -1,28 +1,60 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { History, Play, RotateCcw, Trophy } from "lucide-react";
3 +import { EyeOff, FileJson, FileText, History, MoreHorizontal, Play, RotateCcw, Share2, Trash2, Trophy } from "lucide-react";
4 4 import { useApp } from "@/components/app/store";
5 5 import { ProviderIcon } from "@/components/brand/provider-icon";
6 6 import { Button } from "@/components/ui/button";
7 7 import { Skeleton } from "@/components/ui/misc";
8 8 import { Tooltip } from "@/components/ui/tooltip";
9 +import { ActionSheet } from "@/components/ui/sheet";
10 +import { ConfirmDialog } from "@/components/common/confirm-dialog";
11 +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
12 +import { useIsMobile, useLongPress } from "@/lib/client/hooks";
9 13 import { cn, formatRelative, formatUsd, truncate } from "@/lib/utils";
10 −import type { ArenaSessionDto } from "./types";
14 +import { sessionBlind, sessionCost, sessionWinner, type ArenaSessionDto } from "./types";
11 15
12 16 interface Props {
13 17 sessions: ArenaSessionDto[] | undefined;
14 18 loading: boolean;
15 19 activeId: string | null;
16 20 disabled?: boolean;
17 − onLoad: (s: ArenaSessionDto) => void;
21 + onOpen: (s: ArenaSessionDto) => void;
18 22 onRerun: (s: ArenaSessionDto) => void;
23 + onDelete: (s: ArenaSessionDto) => Promise<void>;
24 + onExport: (s: ArenaSessionDto, format: "markdown" | "json") => void;
25 + onShare: (s: ArenaSessionDto) => void;
19 26 className?: string;
20 27 }
21 28
22 −export function ArenaHistory({ sessions, loading, activeId, disabled, onLoad, onRerun, className }: Props) {
29 +const PAGE = 8;
30 +
31 +/** Past comparisons: stacked rows on phones (tap = open, long-press/… = actions), table from `md` up. */
32 +export function ArenaHistory({ sessions, loading, activeId, disabled, onOpen, onRerun, onDelete, onExport, onShare, className }: Props) {
23 33 const { modelsByKey, preferences } = useApp();
34 + const isMobile = useIsMobile();
24 35 const [showAll, setShowAll] = React.useState(false);
25 − const list = showAll ? sessions ?? [] : (sessions ?? []).slice(0, 8);
36 + const [menuFor, setMenuFor] = React.useState<ArenaSessionDto | null>(null);
37 + const [deleting, setDeleting] = React.useState<ArenaSessionDto | null>(null);
38 + const list = showAll ? sessions ?? [] : (sessions ?? []).slice(0, PAGE);
39 +
40 + const nameOf = (k: string) => modelsByKey.get(k)?.displayName ?? k.split("/").slice(1).join("/");
41 + const providerOf = (k: string) => modelsByKey.get(k)?.provider ?? k.split("/")[0];
42 +
43 + const rowData = (s: ArenaSessionDto) => {
44 + const winner = sessionWinner(s);
45 + return { winner, winnerName: winner ? nameOf(winner.modelKey) : null, winnerProvider: winner ? providerOf(winner.modelKey) : null, cost: sessionCost(s), blind: sessionBlind(s), active: s.id === activeId };
46 + };
47 +
48 + const actions = (s: ArenaSessionDto) => [
49 + { key: "open", label: "Open (read-only)", icon: <Play />, onSelect: () => onOpen(s), disabled },
50 + { key: "rerun", label: "Run again", icon: <RotateCcw />, onSelect: () => onRerun(s), disabled },
51 + "separator" as const,
52 + { key: "md", label: "Export Markdown", icon: <FileText />, onSelect: () => onExport(s, "markdown") },
53 + { key: "json", label: "Export JSON", icon: <FileJson />, onSelect: () => onExport(s, "json") },
54 + { key: "share", label: "Share…", icon: <Share2 />, onSelect: () => onShare(s) },
55 + "separator" as const,
56 + { key: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => setDeleting(s) },
57 + ];
26 58
27 59 return (
28 60 <section className={cn("min-w-0", className)} aria-label="Past sessions">
@@ -31,75 +63,168 @@ export function ArenaHistory({ sessions, loading, activeId, disabled, onLoad, on
31 63 <h2 className="text-[13px] font-semibold tracking-tight">History</h2>
32 64 {sessions?.length ? <span className="text-[12px] tabular-nums text-fg-subtle">{sessions.length}</span> : null}
33 65 </div>
66 +
34 67 {loading && !sessions ? (
35 68 <div className="space-y-2">
36 69 {[0, 1, 2].map((i) => (
37 − <Skeleton key={i} className="h-[74px]" />
70 + <Skeleton key={i} className="h-[72px]" />
38 71 ))}
39 72 </div>
40 73 ) : !sessions?.length ? (
41 − <p className="rounded-lg border border-dashed border-border px-3 py-4 text-[12.5px] text-fg-muted">Your past comparisons will show up here — prompt, models, winner.</p>
74 + <p className="rounded-lg border border-dashed border-border px-3 py-4 text-[12.5px] text-fg-muted">Your past comparisons will show up here — prompt, models, winner, cost.</p>
75 + ) : isMobile ? (
76 + <ul className="overflow-hidden rounded-xl bg-bg-subtle">
77 + {list.map((s, i) => (
78 + <HistoryRow key={s.id} s={s} data={rowData(s)} first={i === 0} disabled={disabled} nameOf={nameOf} providerOf={providerOf} showCosts={preferences.showCosts} onOpen={() => onOpen(s)} onMore={() => setMenuFor(s)} />
79 + ))}
80 + </ul>
42 81 ) : (
43 − <ul className="space-y-1.5">
44 − {list.map((s) => {
45 − const winner = s.responses.find((r) => r.ratings?.best);
46 − const winnerModel = winner ? modelsByKey.get(winner.modelKey) : undefined;
47 − const cost = s.responses.reduce((acc, r) => acc + (r.costUsd ?? 0), 0);
48 − const active = s.id === activeId;
49 − return (
50 − <li key={s.id} className={cn("group rounded-lg border bg-bg-elevated p-2.5 transition-colors", active ? "border-accent/60" : "border-border hover:border-border-strong")}>
51 − <button type="button" onClick={() => onLoad(s)} disabled={disabled} className="block w-full text-left disabled:opacity-60" aria-label={`Load session: ${truncate(s.prompt, 60)}`}>
52 − <p className="line-clamp-2 text-[13px] leading-5">{s.prompt}</p>
53 − </button>
54 − <div className="mt-1.5 flex items-center gap-2 text-[11.5px] text-fg-subtle">
55 − <span className="flex items-center -space-x-1">
56 − {s.modelKeys.map((k) => (
57 − <span key={k} className="flex size-5 items-center justify-center rounded-full border border-border bg-bg" title={modelsByKey.get(k)?.displayName ?? k}>
58 − <ProviderIcon provider={modelsByKey.get(k)?.provider ?? k.split("/")[0]} size={11} />
59 − </span>
60 − ))}
61 − </span>
62 − <span className="tabular-nums">{s.modelKeys.length} models</span>
63 − <span>·</span>
64 − <time dateTime={s.createdAt}>{formatRelative(s.createdAt)}</time>
65 − {preferences.showCosts && cost > 0 ? (
66 − <>
67 − <span>·</span>
68 − <span className="tabular-nums" title="Estimated total cost">
69 − ≈ {formatUsd(cost, { precise: cost < 0.01 })}
82 + <div className="overflow-hidden rounded-xl border border-border bg-bg-elevated">
83 + <table className="w-full text-[12.5px]">
84 + <thead>
85 + <tr className="text-left text-[10.5px] uppercase tracking-wide text-fg-subtle">
86 + <th className="px-3 py-2 font-medium">Prompt</th>
87 + <th className="px-3 py-2 font-medium">Models</th>
88 + <th className="px-3 py-2 font-medium">Winner</th>
89 + {preferences.showCosts ? <th className="px-3 py-2 text-right font-medium">Cost</th> : null}
90 + <th className="px-3 py-2 font-medium">Date</th>
91 + <th className="px-2 py-2" aria-label="Actions" />
92 + </tr>
93 + </thead>
94 + <tbody>
95 + {list.map((s) => {
96 + const d = rowData(s);
97 + return (
98 + <tr key={s.id} className={cn("group border-t border-hairline transition-colors hover:bg-bg-subtle/60", d.active && "bg-accent-soft/40")}>
99 + <td className="max-w-[420px] px-3 py-2">
100 + <button type="button" onClick={() => onOpen(s)} disabled={disabled} className="block w-full text-left disabled:opacity-60" aria-label={`Open session: ${truncate(s.prompt, 60)}`}>
101 + <span className="line-clamp-2 leading-5">{s.prompt}</span>
102 + </button>
103 + {d.blind ? (
104 + <span className="mt-0.5 inline-flex items-center gap-1 text-[10.5px] text-fg-subtle">
105 + <EyeOff className="size-3" /> blind
106 + </span>
107 + ) : null}
108 + </td>
109 + <td className="px-3 py-2">
110 + <span className="flex items-center -space-x-1">
111 + {s.modelKeys.map((k) => (
112 + <span key={k} className="flex size-6 items-center justify-center rounded-full border border-border bg-bg" title={nameOf(k)}>
113 + <ProviderIcon provider={providerOf(k)} size={12} />
114 + </span>
115 + ))}
70 116 </span>
71 − </>
72 − ) : null}
73 − <span className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
74 − <Tooltip content="Load stored responses">
75 − <Button variant="ghost" size="icon-sm" disabled={disabled} onClick={() => onLoad(s)} aria-label="Load">
76 − <Play />
77 − </Button>
78 − </Tooltip>
79 − <Tooltip content="Run again with the same prompt and models">
80 − <Button variant="ghost" size="icon-sm" disabled={disabled} onClick={() => onRerun(s)} aria-label="Run again">
81 − <RotateCcw />
82 − </Button>
83 − </Tooltip>
84 − </span>
85 − </div>
86 − {winner ? (
87 − <div className="mt-1.5 inline-flex items-center gap-1.5 rounded-md bg-accent-soft px-2 py-0.5 text-[11.5px] font-medium text-accent">
88 − <Trophy className="size-3" />
89 − <ProviderIcon provider={winner.provider} size={11} />
90 − {winnerModel?.displayName ?? winner.modelKey}
91 − </div>
92 − ) : null}
93 − </li>
94 − );
95 − })}
96 − </ul>
117 + </td>
118 + <td className="px-3 py-2">
119 + {d.winner ? (
120 + <span className="inline-flex max-w-[200px] items-center gap-1.5 rounded-md bg-accent-soft px-2 py-0.5 text-[11.5px] font-medium text-accent">
121 + <Trophy className="size-3 shrink-0" />
122 + <ProviderIcon provider={d.winnerProvider} size={11} />
123 + <span className="truncate">{d.winnerName}</span>
124 + </span>
125 + ) : (
126 + <span className="text-fg-subtle">—</span>
127 + )}
128 + </td>
129 + {preferences.showCosts ? <td className="px-3 py-2 text-right tabular-nums text-fg-muted">{d.cost > 0 ? `≈ ${formatUsd(d.cost, { precise: d.cost < 0.01 })}` : "—"}</td> : null}
130 + <td className="whitespace-nowrap px-3 py-2 text-fg-muted">
131 + <time dateTime={s.createdAt}>{formatRelative(s.createdAt)}</time>
132 + </td>
133 + <td className="px-2 py-2">
134 + <div className="flex items-center justify-end gap-0.5">
135 + <Tooltip content="Run again">
136 + <Button variant="ghost" size="icon-sm" className="hover-reveal" disabled={disabled} onClick={() => onRerun(s)} aria-label="Run again">
137 + <RotateCcw />
138 + </Button>
139 + </Tooltip>
140 + <DropdownMenu>
141 + <DropdownMenuTrigger asChild>
142 + <Button variant="ghost" size="icon-sm" aria-label="More actions">
143 + <MoreHorizontal />
144 + </Button>
145 + </DropdownMenuTrigger>
146 + <DropdownMenuContent align="end">
147 + {actions(s).map((a, i) =>
148 + a === "separator" ? (
149 + <DropdownMenuSeparator key={`sep-${i}`} />
150 + ) : (
151 + <DropdownMenuItem key={a.key} disabled={a.disabled} destructive={"destructive" in a && a.destructive} onSelect={a.onSelect}>
152 + {a.icon}
153 + {a.label}
154 + </DropdownMenuItem>
155 + ),
156 + )}
157 + </DropdownMenuContent>
158 + </DropdownMenu>
159 + </div>
160 + </td>
161 + </tr>
162 + );
163 + })}
164 + </tbody>
165 + </table>
166 + </div>
97 167 )}
98 − {(sessions?.length ?? 0) > 8 ? (
168 +
169 + {(sessions?.length ?? 0) > PAGE ? (
99 170 <Button variant="link" size="sm" className="mt-2 text-[12.5px]" onClick={() => setShowAll((v) => !v)}>
100 171 {showAll ? "Show fewer" : `Show all ${sessions!.length}`}
101 172 </Button>
102 173 ) : null}
174 +
175 + <ActionSheet open={Boolean(menuFor)} onOpenChange={(o) => !o && setMenuFor(null)} title={menuFor ? truncate(menuFor.prompt, 80) : undefined} items={menuFor ? actions(menuFor) : []} />
176 + <ConfirmDialog
177 + open={Boolean(deleting)}
178 + onOpenChange={(o) => !o && setDeleting(null)}
179 + title="Delete this comparison?"
180 + description="The prompt, every response, your votes and any public link are removed. Usage records are kept for your cost history."
181 + confirmLabel="Delete"
182 + destructive
183 + onConfirm={async () => {
184 + if (deleting) await onDelete(deleting);
185 + }}
186 + />
103 187 </section>
104 188 );
105 189 }
190 +
191 +interface RowData {
192 + winner: ReturnType<typeof sessionWinner>;
193 + winnerName: string | null;
194 + winnerProvider: string | null;
195 + cost: number;
196 + blind: boolean;
197 + active: boolean;
198 +}
199 +
200 +function HistoryRow({ s, data: d, first, disabled, nameOf, providerOf, showCosts, onOpen, onMore }: { s: ArenaSessionDto; data: RowData; first: boolean; disabled?: boolean; nameOf: (k: string) => string; providerOf: (k: string) => string; showCosts: boolean; onOpen: () => void; onMore: () => void }) {
201 + const press = useLongPress({ onLongPress: onMore, onClick: () => !disabled && onOpen() });
202 + return (
203 + <li className={cn("relative flex items-stretch", !first && "border-t border-hairline", d.active && "bg-accent-soft/40")}>
204 + <div {...press} className="tap flex min-h-[64px] min-w-0 flex-1 select-none flex-col justify-center gap-1 px-3 py-2.5 text-left active:bg-bg-muted" role="button" tabIndex={0} aria-label={`Open session: ${truncate(s.prompt, 60)}`} onKeyDown={(e) => e.key === "Enter" && onOpen()}>
205 + <p className="line-clamp-2 text-[13.5px] leading-5">{s.prompt}</p>
206 + <div className="flex min-w-0 items-center gap-2 text-[11.5px] text-fg-subtle">
207 + <span className="flex items-center -space-x-1">
208 + {s.modelKeys.map((k) => (
209 + <span key={k} className="flex size-5 items-center justify-center rounded-full border border-border bg-bg" title={nameOf(k)}>
210 + <ProviderIcon provider={providerOf(k)} size={11} />
211 + </span>
212 + ))}
213 + </span>
214 + {d.blind ? <EyeOff className="size-3" aria-label="Blind" /> : null}
215 + <time dateTime={s.createdAt}>{formatRelative(s.createdAt)}</time>
216 + {showCosts && d.cost > 0 ? <span className="tabular-nums">≈ {formatUsd(d.cost, { precise: d.cost < 0.01 })}</span> : null}
217 + {d.winner ? (
218 + <span className="ml-auto inline-flex min-w-0 items-center gap-1 rounded bg-accent-soft px-1.5 py-0.5 font-medium text-accent">
219 + <Trophy className="size-3 shrink-0" />
220 + <span className="truncate">{d.winnerName}</span>
221 + </span>
222 + ) : null}
223 + </div>
224 + </div>
225 + <button type="button" onClick={onMore} className="flex w-11 shrink-0 items-center justify-center text-fg-subtle active:bg-bg-muted" aria-label="More actions">
226 + <MoreHorizontal className="size-4" />
227 + </button>
228 + </li>
229 + );
230 +}
modified src/components/arena/arena-view.tsx +430 −204
@@ -1,7 +1,8 @@
1 1 "use client";
2 2 import * as React from "react";
3 3 import Link from "next/link";
4 −import { AlertTriangle, Columns3, KeyRound, Menu, Play, Quote, Square, Swords, Timer, Trophy } from "lucide-react";
4 +import { useRouter, useSearchParams } from "next/navigation";
5 +import { AlertTriangle, Columns3, Eye, EyeOff, FileJson, FileText, KeyRound, LayoutGrid, Menu, MoreHorizontal, Plus, Quote, Share2, Square, Swords, Timer, Trophy } from "lucide-react";
5 6 import { useApp } from "@/components/app/store";
6 7 import { api, streamEvents, ClientApiError, useApi } from "@/lib/client/api";
7 8 import type { PolyModel } from "@/lib/client/types";
@@ -9,18 +10,34 @@ import { Composer, type PendingAttachment } from "@/components/chat/composer";
9 10 import { ModelConfig, type ChatSettings } from "@/components/chat/model-config";
10 11 import { Button } from "@/components/ui/button";
11 12 import { Kbd } from "@/components/ui/misc";
13 +import { Segmented } from "@/components/ui/segmented";
12 14 import { Switch } from "@/components/ui/switch";
13 −import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
14 15 import { Tooltip } from "@/components/ui/tooltip";
15 16 import { toast } from "@/components/ui/toast";
17 +import { ActionSheet } from "@/components/ui/sheet";
18 +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
16 19 import { PROVIDERS } from "@/lib/client/providers";
20 +import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks";
21 +import { estimateAttachmentTokens, estimateTextTokens } from "@/lib/client/tokens";
22 +import { analyzePrompt } from "@/lib/client/router";
23 +import { categoryFromTask, computeWinner, type TaskCategory } from "@/lib/arena/scoring";
24 +import { liveMetrics } from "@/lib/arena/metrics";
17 25 import { cn } from "@/lib/utils";
18 26 import { ArenaModelPicker } from "./model-picker";
19 −import { ArenaColumn, StatusDot } from "./arena-column";
27 +import { ArenaColumn } from "./arena-column";
20 28 import { ArenaHistory } from "./arena-history";
21 −import { buildComposerModel, buildSharedModel, columnFromResponse, computeWinners, emptyColumn, isFinal, type ArenaResponseDto, type ArenaSessionDto, type ColumnState } from "./types";
22 −
23 −const XL_COLS: Record<number, string> = { 1: "xl:grid-cols-1", 2: "xl:grid-cols-2", 3: "xl:grid-cols-3", 4: "xl:grid-cols-4" };
29 +import { ModelTabs } from "./model-tabs";
30 +import { WinnerCard } from "./winner-card";
31 +import { ComparisonTable } from "./comparison-table";
32 +import { ArenaShareSheet } from "./share-sheet";
33 +import { useCustomCriteria } from "./vote-panel";
34 +import { useTick } from "./metrics-strip";
35 +import { useFlip } from "./blind";
36 +import { downloadArenaExport } from "./download";
37 +import { blindLabel } from "@/lib/arena/scoring";
38 +import { buildComposerModel, buildSharedModel, columnFromResponse, computeWinners, emptyColumn, isFinal, sessionAttachmentCount, sessionBlind, sessionOrder, type ArenaResponseDto, type ArenaSessionDto, type ArenaVoteDto, type ColumnState } from "./types";
39 +
40 +type Layout = "grid" | "columns";
24 41
25 42 interface ActiveSession {
26 43 id: string;
@@ -29,6 +46,12 @@ interface ActiveSession {
29 46 attachmentCount: number;
30 47 readOnly: boolean;
31 48 createdAt: string;
49 + blind: boolean;
50 + /** Display permutation (Blind Arena shuffles positions). */
51 + order: number[];
52 + /** Prompt + system prompt + attachments token estimate (live cost). */
53 + inputTokens: number;
54 + category: TaskCategory;
32 55 }
33 56
34 57 type ArenaEvent =
@@ -40,27 +63,25 @@ type ArenaEvent =
40 63 | { type: "error"; error: { code: string; message: string } }
41 64 | { type: "done"; response: ArenaResponseDto; status: "complete" | "stopped" | "error" };
42 65
43 −function useMediaQuery(query: string): boolean {
44 − const subscribe = React.useCallback(
45 − (cb: () => void) => {
46 − const m = window.matchMedia(query);
47 − m.addEventListener("change", cb);
48 − return () => m.removeEventListener("change", cb);
49 − },
50 − [query],
51 − );
52 − return React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
53 −}
54 −
55 66 function cleanSettings(s: ChatSettings): ChatSettings | undefined {
56 67 const out = Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))) as ChatSettings;
57 68 return Object.keys(out).length ? out : undefined;
58 69 }
59 70
71 +function gridClass(n: number, layout: Layout): string {
72 + if (n <= 1) return "grid-cols-1";
73 + if (n === 2) return "grid-cols-1 md:grid-cols-2";
74 + if (layout === "columns") return cn("grid-cols-1 md:grid-cols-2", n === 3 ? "xl:grid-cols-3" : "xl:grid-cols-4");
75 + return "grid-cols-1 md:grid-cols-2";
76 +}
77 +
60 78 export function ArenaView() {
61 79 const { modelsByKey, connectedProviders, loadingModels, preferences, setSidebarOpen } = useApp();
62 80 const history = useApi<{ sessions: ArenaSessionDto[] }>("/api/arena");
63 − const isMobile = useMediaQuery("(max-width: 767px)");
81 + const isMobile = useIsMobile();
82 + const router = useRouter();
83 + const searchParams = useSearchParams();
84 + const criteria = useCustomCriteria();
64 85
65 86 const [selected, setSelected] = React.useState<string[]>([]);
66 87 const [draft, setDraft] = React.useState("");
@@ -69,11 +90,17 @@ export function ArenaView() {
69 90 const [attachments, setAttachments] = React.useState<PendingAttachment[]>([]);
70 91 const [columns, setColumns] = React.useState<ColumnState[]>([]);
71 92 const [session, setSession] = React.useState<ActiveSession | null>(null);
93 + const [votes, setVotes] = React.useState<ArenaVoteDto[]>([]);
72 94 const [running, setRunning] = React.useState(false);
73 95 const [missingKeys, setMissingKeys] = React.useState<string[] | null>(null);
74 96 const [syncScroll, setSyncScroll] = React.useState(false);
75 − const [ratingBusy, setRatingBusy] = React.useState(false);
76 − const [tab, setTab] = React.useState<string | null>(null);
97 + const [layout, setLayout] = React.useState<Layout>("grid");
98 + const [blindNext, setBlindNext] = React.useState(false);
99 + const [revealed, setRevealed] = React.useState(true);
100 + const [flipping, flip] = useFlip(160);
101 + const [voteBusy, setVoteBusy] = React.useState(false);
102 + const [menuOpen, setMenuOpen] = React.useState(false);
103 + const [shareFor, setShareFor] = React.useState<string | null>(null);
77 104
78 105 // Single source of truth for column data while streaming: a mutable map, flushed to state at ~25fps.
79 106 const mapRef = React.useRef<Map<string, ColumnState>>(new Map());
@@ -114,6 +141,7 @@ export function ArenaView() {
114 141 [],
115 142 );
116 143
144 + // ----- derived --------------------------------------------------------------------------------
117 145 const selectedModels = React.useMemo(() => selected.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m)), [selected, modelsByKey]);
118 146 const sharedModel = React.useMemo(() => buildSharedModel(selectedModels), [selectedModels]);
119 147 const composerModel = React.useMemo(() => buildComposerModel(selectedModels, selected.length), [selectedModels, selected.length]);
@@ -122,12 +150,37 @@ export function ArenaView() {
122 150 const disconnected = React.useMemo(() => selectedModels.filter((m) => !connectedProviders.has(m.provider)), [selectedModels, connectedProviders]);
123 151 const winners = React.useMemo(() => computeWinners(columns), [columns]);
124 152 const allFinal = columns.length > 0 && columns.every((c) => isFinal(c.status));
153 + const anyLive = columns.some((c) => !isFinal(c.status) && c.status !== "idle");
154 + const now = useTick(anyLive || running, 500);
155 +
156 + const ordered = React.useMemo(() => {
157 + if (!session?.blind || session.order.length !== columns.length) return columns;
158 + return session.order.map((i) => columns[i]).filter(Boolean);
159 + }, [columns, session]);
160 + const hidden = Boolean(session?.blind) && !revealed;
161 + const { ref: carouselRef, index: carouselIndex, scrollTo: scrollCarousel } = useSnapCarousel<HTMLDivElement>(ordered.length);
162 +
163 + const wonBy = React.useMemo(() => {
164 + const m = new Map<string, Set<string>>();
165 + for (const v of votes) {
166 + const s = m.get(v.responseId) ?? new Set<string>();
167 + s.add(v.criterion);
168 + m.set(v.responseId, s);
169 + }
170 + return m;
171 + }, [votes]);
172 + const EMPTY = React.useMemo(() => new Set<string>(), []);
173 + const winner = React.useMemo(() => (allFinal && votes.length ? computeWinner(columns.map((c) => c.response).filter((r): r is ArenaResponseDto => Boolean(r)), votes) : null), [allFinal, votes, columns]);
174 + const winnerModel = winner ? modelsByKey.get(winner.modelKey) : undefined;
175 + const winnerIndex = winner ? ordered.findIndex((c) => c.responseId === winner.responseId) : -1;
176 +
177 + const nameOf = React.useCallback((c: ColumnState, i: number) => (hidden ? blindLabel(i) : modelsByKey.get(c.modelKey)?.displayName ?? c.modelKey.split("/").slice(1).join("/")), [hidden, modelsByKey]);
178 + const providerOf = React.useCallback((c: ColumnState) => modelsByKey.get(c.modelKey)?.provider ?? c.response?.provider ?? c.modelKey.split("/")[0], [modelsByKey]);
125 179
126 180 const runReason = loadingModels ? "Loading models…" : connectedProviders.size === 0 ? "Connect a provider first" : selected.length === 0 ? "Add at least one model" : disconnected.length ? `${disconnected.map((m) => PROVIDERS[m.provider].shortName).join(", ")} not connected` : !draft.trim() && attachments.length === 0 ? "Type a prompt" : null;
127 181
128 182 const stop = React.useCallback(() => abortRef.current?.abort(), []);
129 183
130 − // Esc stops every stream (Composer handles it while focused; this covers the rest of the page).
131 184 React.useEffect(() => {
132 185 if (!running) return;
133 186 const onKey = (e: KeyboardEvent) => {
@@ -137,6 +190,50 @@ export function ArenaView() {
137 190 return () => window.removeEventListener("keydown", onKey);
138 191 }, [running, stop]);
139 192
193 + // ----- open a stored session (history / deep link) ---------------------------------------------
194 + const openSession = React.useCallback(
195 + (s: ArenaSessionDto) => {
196 + if (running) return;
197 + const cols = s.modelKeys.map((k) => {
198 + const r = s.responses.find((x) => x.modelKey === k);
199 + return r ? columnFromResponse(r) : { ...emptyColumn(k), status: "stopped" as const };
200 + });
201 + replaceColumns(cols);
202 + setVotes(s.votes ?? []);
203 + setSession({ id: s.id, prompt: s.prompt, systemPrompt: s.systemPrompt, attachmentCount: sessionAttachmentCount(s), readOnly: true, createdAt: s.createdAt, blind: sessionBlind(s), order: sessionOrder(s), inputTokens: estimateTextTokens(s.prompt) + estimateTextTokens(s.systemPrompt ?? ""), category: categoryFromTask(analyzePrompt(s.prompt).task) });
204 + setRevealed(true);
205 + setMissingKeys(null);
206 + scrollCarousel(0, false);
207 + document.querySelector<HTMLElement>("[data-arena-main]")?.scrollTo({ top: 0 });
208 + },
209 + [running, replaceColumns, scrollCarousel],
210 + );
211 +
212 + // ----- deep links: ?models=a,b · ?session=<id> (one-shot, guarded by `appliedLink`) -----------
213 + const appliedLink = React.useRef<string | null>(null);
214 + React.useEffect(() => {
215 + if (loadingModels || !modelsByKey.size) return;
216 + const key = searchParams.toString();
217 + if (appliedLink.current === key) return;
218 + appliedLink.current = key;
219 + const models = searchParams.get("models");
220 + if (models) {
221 + const keys = [...new Set(models.split(",").map((k) => k.trim()).filter((k) => modelsByKey.has(k)))].slice(0, 4);
222 + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot URL → state
223 + if (keys.length) setSelected(keys);
224 + const missing = models.split(",").filter((k) => k.trim() && !modelsByKey.has(k.trim()));
225 + if (missing.length) toast.info("Some models were skipped", `${missing.length} model${missing.length === 1 ? " is" : "s are"} not available on your account.`);
226 + }
227 + const sessionId = searchParams.get("session");
228 + if (sessionId) {
229 + api<{ session: ArenaSessionDto }>(`/api/arena/${encodeURIComponent(sessionId)}`)
230 + .then(({ session: s }) => openSession(s))
231 + .catch(() => toast.error("Session not found", "This Arena session does not exist or was deleted."));
232 + }
233 + if (models || sessionId) router.replace("/app/arena", { scroll: false });
234 + }, [searchParams, modelsByKey, loadingModels, router, openSession]);
235 +
236 + // ----- streaming ------------------------------------------------------------------------------
140 237 const streamOne = React.useCallback(
141 238 async (sessionId: string, modelKey: string, signal: AbortSignal) => {
142 239 const map = mapRef.current;
@@ -219,22 +316,25 @@ export function ArenaView() {
219 316
220 317 setRunning(true);
221 318 setMissingKeys(null);
319 + setVotes([]);
222 320 const attIds = attachments.map((a) => a.id);
321 + const inputTokens = estimateTextTokens(text) + estimateTextTokens(systemPrompt) + attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0);
223 322 const controller = new AbortController();
224 323 abortRef.current = controller;
225 324 replaceColumns(keys.map((k) => ({ ...emptyColumn(k), status: "waiting" })));
226 325 setSession(null);
227 − setTab(keys[0]);
326 + setRevealed(!blindNext);
327 + scrollCarousel(0, false);
228 328
229 329 let created: ArenaSessionDto | null = null;
230 330 try {
231 331 const res = await api<{ session: ArenaSessionDto }>("/api/arena", {
232 332 method: "POST",
233 − json: { prompt: text || "(see attachments)", systemPrompt: systemPrompt.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attIds.length ? attIds : undefined },
333 + json: { prompt: text || "(see attachments)", systemPrompt: systemPrompt.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attIds.length ? attIds : undefined, blind: blindNext || undefined },
234 334 });
235 335 created = res.session;
236 336 setAttachments([]);
237 − setSession({ id: created.id, prompt: created.prompt, systemPrompt: created.systemPrompt, attachmentCount: attIds.length, readOnly: false, createdAt: created.createdAt });
337 + setSession({ id: created.id, prompt: created.prompt, systemPrompt: created.systemPrompt, attachmentCount: attIds.length, readOnly: false, createdAt: created.createdAt, blind: sessionBlind(created), order: sessionOrder(created), inputTokens, category: categoryFromTask(analyzePrompt(text, attachments).task) });
238 338 await Promise.allSettled(keys.map((k) => streamOne(created!.id, k, controller.signal)));
239 339
240 340 // Stopped streams may have missed their `done` frame — pull the persisted rows for metrics.
@@ -272,50 +372,44 @@ export function ArenaView() {
272 372 abortRef.current = null;
273 373 }
274 374 },
275 − [running, selected, attachments, disconnected, systemPrompt, settings, replaceColumns, streamOne, history, flushNow],
375 + [running, selected, attachments, disconnected, systemPrompt, settings, blindNext, replaceColumns, streamOne, history, flushNow, scrollCarousel],
276 376 );
277 377
278 − const rate = React.useCallback(
279 − async (responseId: string, key: string, value: boolean) => {
280 − setRatingBusy(true);
378 + // ----- blind reveal ----------------------------------------------------------------------------
379 + const reveal = React.useCallback(() => {
380 + if (revealed) return;
381 + flip(() => setRevealed(true));
382 + }, [revealed, flip]);
383 +
384 + // ----- votes -----------------------------------------------------------------------------------
385 + const vote = React.useCallback(
386 + async (responseId: string, criterionId: string, on: boolean) => {
387 + if (!session) return;
388 + setVoteBusy(true);
281 389 try {
282 − const { response } = await api<{ response: ArenaResponseDto }>("/api/arena", { method: "PATCH", json: { responseId, ratings: { [key]: value } } });
390 + const res = on
391 + ? await api<{ votes: ArenaVoteDto[]; ratings: Record<string, Record<string, boolean>> }>("/api/arena/vote", { method: "POST", json: { sessionId: session.id, responseId, criterion: criterionId, category: session.category } })
392 + : await api<{ votes: ArenaVoteDto[]; ratings: Record<string, Record<string, boolean>> }>("/api/arena/vote", { method: "DELETE", json: { sessionId: session.id, criterion: criterionId } });
393 + setVotes(res.votes);
283 394 for (const c of mapRef.current.values()) {
284 − if (c.responseId === responseId) {
285 − c.response = response;
286 − dirtyRef.current.add(c.modelKey);
287 − } else if (value && key.startsWith("best") && c.response?.ratings?.[key]) {
288 − c.response = { ...c.response, ratings: { ...c.response.ratings, [key]: false } };
395 + if (c.response && res.ratings[c.response.id]) {
396 + c.response = { ...c.response, ratings: res.ratings[c.response.id] };
289 397 dirtyRef.current.add(c.modelKey);
290 398 }
291 399 }
292 400 flushNow();
401 + if (on && criterionId === "best") reveal();
293 402 void history.mutate();
294 403 } catch (e) {
295 − toast.error("Could not save rating", (e as Error).message);
404 + toast.error("Could not save your vote", (e as Error).message);
296 405 } finally {
297 − setRatingBusy(false);
406 + setVoteBusy(false);
298 407 }
299 408 },
300 − [flushNow, history],
301 − );
302 −
303 − const loadSession = React.useCallback(
304 − (s: ArenaSessionDto) => {
305 − if (running) return;
306 − const cols = s.modelKeys.map((k) => {
307 − const r = s.responses.find((x) => x.modelKey === k);
308 − return r ? columnFromResponse(r) : { ...emptyColumn(k), status: "stopped" as const };
309 − });
310 − replaceColumns(cols);
311 − setSession({ id: s.id, prompt: s.prompt, systemPrompt: s.systemPrompt, attachmentCount: ((s.settings as { attachmentIds?: string[] })?.attachmentIds ?? []).length, readOnly: true, createdAt: s.createdAt });
312 − setTab(s.modelKeys[0] ?? null);
313 − setMissingKeys(null);
314 − window.scrollTo({ top: 0 });
315 − },
316 − [running, replaceColumns],
409 + [session, flushNow, history, reveal],
317 410 );
318 411
412 + // ----- history ---------------------------------------------------------------------------------
319 413 const rerunSession = React.useCallback(
320 414 (s: ArenaSessionDto) => {
321 415 if (running) return;
@@ -325,18 +419,53 @@ export function ArenaView() {
325 419 setSelected(usable.slice(0, 4));
326 420 setDraft(s.prompt);
327 421 setSystemPrompt(s.systemPrompt ?? "");
328 − const { attachmentIds: _a, ...rest } = (s.settings ?? {}) as ChatSettings & { attachmentIds?: string[] };
422 + const { attachmentIds: _a, blind: _b, blindOrder: _o, ...rest } = (s.settings ?? {}) as ChatSettings & { attachmentIds?: string[]; blind?: boolean; blindOrder?: number[] };
329 423 void _a;
424 + void _o;
425 + setBlindNext(Boolean(_b));
330 426 setSettings(rest);
331 427 replaceColumns([]);
332 428 setSession(null);
429 + setVotes([]);
333 430 setMissingKeys(null);
334 431 requestAnimationFrame(() => document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Message"]')?.focus());
335 432 },
336 433 [running, modelsByKey, connectedProviders, replaceColumns],
337 434 );
338 435
339 − // Sync scroll across columns (ratio-based, loop-guarded).
436 + const reset = React.useCallback(() => {
437 + if (running) return;
438 + replaceColumns([]);
439 + setSession(null);
440 + setVotes([]);
441 + setMissingKeys(null);
442 + setRevealed(true);
443 + }, [running, replaceColumns]);
444 +
445 + const deleteSession = React.useCallback(
446 + async (s: ArenaSessionDto) => {
447 + try {
448 + await api(`/api/arena/${encodeURIComponent(s.id)}`, { method: "DELETE" });
449 + if (session?.id === s.id) reset();
450 + await history.mutate();
451 + toast.success("Comparison deleted");
452 + } catch (e) {
453 + toast.error("Could not delete", (e as Error).message);
454 + }
455 + },
456 + [session, reset, history],
457 + );
458 +
459 + const exportSession = React.useCallback(async (id: string, format: "markdown" | "json") => {
460 + try {
461 + const name = await downloadArenaExport(id, format);
462 + toast.success("Export ready", name);
463 + } catch (e) {
464 + toast.error("Export failed", (e as Error).message);
465 + }
466 + }, []);
467 +
468 + // ----- sync scroll -----------------------------------------------------------------------------
340 469 const bodies = React.useRef(new Map<string, HTMLDivElement>());
341 470 const syncing = React.useRef(false);
342 471 const onBodyScroll = React.useCallback(
@@ -356,8 +485,66 @@ export function ArenaView() {
356 485 [syncScroll],
357 486 );
358 487
359 − const activeTab = columns.some((c) => c.modelKey === tab) ? tab! : columns[0]?.modelKey;
360 488 const noProviders = !loadingModels && connectedProviders.size === 0;
489 + const canAct = Boolean(session) && !running;
490 +
491 + const menuItems = session
492 + ? [
493 + { key: "md", label: "Export Markdown", icon: <FileText />, onSelect: () => exportSession(session.id, "markdown"), disabled: !canAct },
494 + { key: "json", label: "Export JSON", icon: <FileJson />, onSelect: () => exportSession(session.id, "json"), disabled: !canAct },
495 + { key: "share", label: "Share…", icon: <Share2 />, onSelect: () => setShareFor(session.id), disabled: !canAct },
496 + "separator" as const,
497 + { key: "board", label: "Scoreboard", icon: <Trophy />, onSelect: () => router.push("/app/arena/scoreboard") },
498 + ]
499 + : [{ key: "board", label: "Scoreboard", icon: <Trophy />, onSelect: () => router.push("/app/arena/scoreboard") }];
500 +
501 + const tabItems = ordered.map((c, i) => {
502 + const m = modelsByKey.get(c.modelKey);
503 + const lm = liveMetrics(c, m, session?.inputTokens ?? 0, now);
504 + return { key: c.modelKey, name: nameOf(c, i), provider: providerOf(c), status: c.status, tokensPerSecond: lm.tokensPerSecond, isWinner: winner?.responseId === c.responseId, hidden, blindIndex: i };
505 + });
506 + const comparisonRows = allFinal
507 + ? ordered.map((c, i) => ({ key: c.modelKey, name: nameOf(c, i), provider: providerOf(c), hidden, blindIndex: i, metrics: liveMetrics(c, modelsByKey.get(c.modelKey), session?.inputTokens ?? 0, now), criteriaWon: c.responseId ? [...(wonBy.get(c.responseId) ?? [])] : [], isWinner: winner?.responseId === c.responseId }))
508 + : [];
509 +
510 + const renderColumn = (c: ColumnState, i: number, extra?: { className?: string; style?: React.CSSProperties; sync?: boolean }) => (
511 + <ArenaColumn
512 + key={c.modelKey}
513 + column={c}
514 + model={modelsByKey.get(c.modelKey)}
515 + index={i}
516 + hidden={hidden}
517 + flipping={flipping}
518 + onReveal={hidden ? reveal : undefined}
519 + winners={winners}
520 + isWinner={Boolean(winner) && winner!.responseId === c.responseId}
521 + criteria={criteria.all}
522 + won={c.responseId ? wonBy.get(c.responseId) ?? EMPTY : EMPTY}
523 + onVote={vote}
524 + onAddCriterion={(label) => {
525 + const added = criteria.add(label);
526 + if (!added) toast.warning("Give the criterion a name");
527 + }}
528 + onRemoveCriterion={criteria.remove}
529 + voteBusy={voteBusy}
530 + inputTokens={session?.inputTokens ?? 0}
531 + now={now}
532 + wrapCode={preferences.codeWrap}
533 + showReasoning={preferences.showReasoning}
534 + showCosts={preferences.showCosts}
535 + bodyRef={
536 + extra?.sync
537 + ? (el) => {
538 + if (el) bodies.current.set(c.modelKey, el);
539 + else bodies.current.delete(c.modelKey);
540 + }
541 + : undefined
542 + }
543 + onBodyScroll={extra?.sync ? onBodyScroll : undefined}
544 + className={extra?.className}
545 + style={extra?.style}
546 + />
547 + );
361 548
362 549 return (
363 550 <div className="flex h-full min-h-0 flex-col">
@@ -373,173 +560,212 @@ export function ArenaView() {
373 560 <h1 className="text-[14px] font-semibold tracking-tight">Arena</h1>
374 561 <p className="hidden truncate text-[11.5px] text-fg-muted sm:block">One prompt. Up to 4 models. Side by side.</p>
375 562 </div>
376 − <div className="ml-auto flex items-center gap-2">
377 − {columns.length > 1 ? (
378 − <label className="hidden items-center gap-2 text-[12px] text-fg-muted md:flex">
563 + <div className="ml-auto flex items-center gap-1.5 sm:gap-2">
564 + {ordered.length >= 3 ? (
565 + <Segmented
566 + size="sm"
567 + ariaLabel="Layout"
568 + className="hidden md:inline-flex"
569 + value={layout}
570 + onChange={setLayout}
571 + options={[
572 + { value: "grid", label: <span className="sr-only">Grid</span>, icon: <LayoutGrid /> },
573 + { value: "columns", label: <span className="sr-only">Columns</span>, icon: <Columns3 /> },
574 + ]}
575 + />
576 + ) : null}
577 + {ordered.length > 1 ? (
578 + <label className="hidden items-center gap-2 text-[12px] text-fg-muted lg:flex">
379 579 <Switch size="sm" checked={syncScroll} onCheckedChange={setSyncScroll} aria-label="Sync scroll" />
380 580 Sync scroll
381 581 </label>
382 582 ) : null}
583 + {hidden && allFinal ? (
584 + <Button variant="outline" size="sm" onClick={reveal}>
585 + <Eye /> Reveal
586 + </Button>
587 + ) : null}
383 588 {running ? (
384 589 <Button variant="secondary" size="sm" onClick={stop}>
385 590 <Square className="size-3.5 fill-current" /> Stop <Kbd className="ml-1 hidden sm:inline-flex">esc</Kbd>
386 591 </Button>
592 + ) : columns.length ? (
593 + <Tooltip content="New comparison">
594 + <Button variant="ghost" size="icon-sm" onClick={reset} aria-label="New comparison">
595 + <Plus />
596 + </Button>
597 + </Tooltip>
598 + ) : null}
599 + <Tooltip content="Scoreboard">
600 + <Button asChild variant="ghost" size="icon-sm" className="hidden sm:inline-flex" aria-label="Scoreboard">
601 + <Link href="/app/arena/scoreboard">
602 + <Trophy />
603 + </Link>
604 + </Button>
605 + </Tooltip>
606 + {isMobile ? (
607 + <Button variant="ghost" size="icon-sm" onClick={() => setMenuOpen(true)} aria-label="More actions">
608 + <MoreHorizontal />
609 + </Button>
387 610 ) : (
388 − <Tooltip content={runReason ?? "Run all selected models (⌘↵)"}>
389 − <span className="inline-flex">
390 − <Button variant="accent" size="sm" disabled={Boolean(runReason)} onClick={() => run(draft)}>
391 − <Play className="size-3.5 fill-current" /> Run
611 + <DropdownMenu>
612 + <DropdownMenuTrigger asChild>
613 + <Button variant="ghost" size="icon-sm" aria-label="More actions">
614 + <MoreHorizontal />
392 615 </Button>
393 − </span>
394 − </Tooltip>
616 + </DropdownMenuTrigger>
617 + <DropdownMenuContent align="end">
618 + {menuItems.map((it, i) =>
619 + it === "separator" ? (
620 + <DropdownMenuSeparator key={`sep-${i}`} />
621 + ) : (
622 + <DropdownMenuItem key={it.key} disabled={it.disabled} onSelect={it.onSelect}>
623 + {it.icon}
624 + {it.label}
625 + </DropdownMenuItem>
626 + ),
627 + )}
628 + </DropdownMenuContent>
629 + </DropdownMenu>
395 630 )}
396 631 </div>
397 632 </header>
398 633
399 − <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
400 − <div className="mx-auto w-full max-w-[1680px] px-3 py-4 sm:px-5 lg:px-6 2xl:grid 2xl:grid-cols-[minmax(0,1fr)_320px] 2xl:gap-6">
401 − <div className="min-w-0 space-y-4">
402 − {/* Setup */}
403 − <section className="space-y-3 rounded-xl border border-border bg-bg-subtle/40 p-3 sm:p-4" aria-label="Setup">
404 − <div className="flex flex-wrap items-start justify-between gap-2">
405 − <ArenaModelPicker selected={selected} onChange={setSelected} disabled={running} />
406 − <ModelConfig model={sharedModel} settings={settings} onChange={setSettings} systemPrompt={systemPrompt} onSystemPromptChange={setSystemPrompt} allowSavePreset={false} />
407 − </div>
408 − <Composer
409 − model={composerModel}
410 − busy={running}
411 − enterToSend={preferences.enterToSend}
412 − attachments={attachments}
413 − onAttachmentsChange={setAttachments}
414 − value={draft}
415 − onValueChange={(v) => {
416 − if (v === "" && keepDraft.current !== null) {
417 − // Composer clears its text after send; the Arena keeps the prompt for iteration.
418 − setDraft(keepDraft.current);
419 − keepDraft.current = null;
420 − return;
421 − }
422 − setDraft(v);
423 − }}
424 − onSend={(t) => {
425 − keepDraft.current = t;
426 − void run(t);
427 − }}
428 − onStop={stop}
429 − placeholder={selected.length ? `Ask ${selected.length === 1 ? "this model" : `all ${selected.length} models`} the same thing…` : "Pick models above, then write one prompt for all of them…"}
430 − />
431 − <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-fg-muted">
432 − {runReason ? (
433 − <span className="inline-flex items-center gap-1.5">
434 − <AlertTriangle className="size-3.5 text-warning" /> {runReason}
435 − </span>
436 − ) : (
437 − <span>
438 − <Kbd>⌘↵</Kbd> runs all {selected.length} model{selected.length === 1 ? "" : "s"} in parallel
439 − </span>
440 − )}
441 − {systemPrompt.trim() ? <span className="text-fg-subtle">· system prompt set</span> : null}
442 − {hasImages && noVision.length ? (
443 − <span className="inline-flex items-center gap-1 text-warning">
444 − <AlertTriangle className="size-3.5" /> Images are skipped for {noVision.map((m) => m.displayName).join(", ")} (no vision).
634 + {/* Scrollable results */}
635 + <main data-arena-main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
636 + <div className="mx-auto w-full max-w-[1680px] space-y-4 px-3 py-3 sm:px-5 sm:py-4 lg:px-6">
637 + {missingKeys ? (
638 + <div className="flex flex-wrap items-center gap-2 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">
639 + <KeyRound className="size-4 shrink-0" />
640 + <span className="min-w-0 flex-1">No API key for {missingKeys.map((p) => PROVIDERS[p as keyof typeof PROVIDERS]?.name ?? p).join(", ")}.</span>
641 + <Button asChild size="xs" variant="outline">
642 + <Link href="/app/settings/providers">Open Providers</Link>
643 + </Button>
644 + </div>
645 + ) : null}
646 +
647 + {columns.length === 0 ? (
648 + <ArenaEmpty noProviders={noProviders} />
649 + ) : (
650 + <section className="space-y-3" aria-label="Results" aria-busy={running}>
651 + {session ? (
652 + <div className="flex flex-wrap items-start gap-2 text-[12.5px] text-fg-muted">
653 + <Quote className="mt-0.5 size-3.5 shrink-0 text-fg-subtle" />
654 + <p className="min-w-0 flex-1 line-clamp-2 leading-5" title={session.prompt}>
655 + {session.prompt}
656 + </p>
657 + <span className="flex shrink-0 flex-wrap items-center gap-1.5 text-[11.5px] text-fg-subtle">
658 + {session.blind ? (
659 + <span className="inline-flex items-center gap-1 rounded bg-bg-muted px-1.5 py-0.5">
660 + <EyeOff className="size-3" /> blind
661 + </span>
662 + ) : null}
663 + {session.attachmentCount ? <span>{session.attachmentCount} attachment{session.attachmentCount === 1 ? "" : "s"}</span> : null}
664 + {session.systemPrompt ? <span title={session.systemPrompt}>system prompt</span> : null}
665 + {session.readOnly ? <span className="rounded bg-bg-muted px-1.5 py-0.5">stored · {new Date(session.createdAt).toLocaleString([], { dateStyle: "medium", timeStyle: "short" })}</span> : null}
445 666 </span>
446 − ) : null}
447 − </div>
448 − {missingKeys ? (
449 − <div className="flex flex-wrap items-center gap-2 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">
450 − <KeyRound className="size-4 shrink-0" />
451 − <span className="min-w-0 flex-1">No API key for {missingKeys.map((p) => PROVIDERS[p as keyof typeof PROVIDERS]?.name ?? p).join(", ")}.</span>
452 − <Button asChild size="xs" variant="outline">
453 − <Link href="/app/settings/providers">Open Providers</Link>
454 − </Button>
455 667 </div>
456 668 ) : null}
457 − </section>
458 669
459 − {/* Results */}
460 − {columns.length === 0 ? (
461 − <ArenaEmpty noProviders={noProviders} />
462 − ) : (
463 − <section className="space-y-3" aria-label="Results" aria-busy={running}>
464 − {session ? (
465 − <div className="flex flex-wrap items-start gap-2 text-[12.5px] text-fg-muted">
466 − <Quote className="mt-0.5 size-3.5 shrink-0 text-fg-subtle" />
467 − <p className="min-w-0 flex-1 line-clamp-2 leading-5" title={session.prompt}>
468 − {session.prompt}
469 − </p>
470 − <span className="flex shrink-0 items-center gap-2 text-[11.5px] text-fg-subtle">
471 − {session.attachmentCount ? <span>{session.attachmentCount} attachment{session.attachmentCount === 1 ? "" : "s"}</span> : null}
472 − {session.systemPrompt ? <span title={session.systemPrompt}>system prompt</span> : null}
473 − {session.readOnly ? <span className="rounded bg-bg-muted px-1.5 py-0.5">stored · {new Date(session.createdAt).toLocaleString([], { dateStyle: "medium", timeStyle: "short" })}</span> : null}
474 − </span>
670 + {isMobile ? (
671 + <>
672 + <div className="sticky top-0 z-10 -mx-3 bg-bg/95 px-3 py-1.5 backdrop-blur-sm">
673 + <ModelTabs items={tabItems} active={carouselIndex} onSelect={(i) => scrollCarousel(i)} />
475 674 </div>
476 − ) : null}
477 −
478 − {isMobile ? (
479 − <Tabs value={activeTab} onValueChange={setTab}>
480 − <TabsList className="h-auto w-full flex-wrap justify-start gap-1 p-1">
481 − {columns.map((c) => {
482 − const m = modelsByKey.get(c.modelKey);
483 − return (
484 − <TabsTrigger key={c.modelKey} value={c.modelKey} className="h-8 max-w-full gap-1.5 px-2.5">
485 − <StatusDot status={c.status} />
486 − <span className="truncate">{m?.displayName ?? c.modelKey.split("/").slice(1).join("/")}</span>
487 − {c.response?.ratings?.best ? <Trophy className="size-3 text-accent" /> : null}
488 − </TabsTrigger>
489 − );
490 − })}
491 − </TabsList>
492 − {columns.map((c) => (
493 − <TabsContent key={c.modelKey} value={c.modelKey} forceMount className="mt-2 data-[state=inactive]:hidden">
494 − <ArenaColumn column={c} model={modelsByKey.get(c.modelKey)} winners={winners} wrapCode={preferences.codeWrap} showReasoning={preferences.showReasoning} showCosts={preferences.showCosts} ratingBusy={ratingBusy} onRate={rate} />
495 − </TabsContent>
496 − ))}
497 − </Tabs>
498 − ) : (
499 − <div className={cn("grid grid-cols-1 gap-3 md:grid-cols-2", XL_COLS[columns.length] ?? "xl:grid-cols-4")}>
500 − {columns.map((c, i) => (
501 − <ArenaColumn
502 − key={c.modelKey}
503 − column={c}
504 − model={modelsByKey.get(c.modelKey)}
505 − winners={winners}
506 − wrapCode={preferences.codeWrap}
507 − showReasoning={preferences.showReasoning}
508 − showCosts={preferences.showCosts}
509 − ratingBusy={ratingBusy}
510 − onRate={rate}
511 − bodyRef={(el) => {
512 − if (el) bodies.current.set(c.modelKey, el);
513 − else bodies.current.delete(c.modelKey);
514 − }}
515 − onBodyScroll={onBodyScroll}
516 − className="animate-fade-up"
517 − style={{ animationDelay: `${i * 40}ms` }}
518 − />
675 + <div ref={carouselRef} className="snap-row" aria-roledescription="carousel" data-no-edge-swipe>
676 + {ordered.map((c, i) => (
677 + <div key={c.modelKey} className="px-0.5">
678 + {renderColumn(c, i)}
679 + </div>
519 680 ))}
520 681 </div>
521 − )}
522 −
523 − {allFinal && !session?.readOnly ? (
524 − <p className="text-[12px] text-fg-subtle">
525 − All done. Rate the answers to remember which model won — <span className="text-fg-muted">Best</span> is exclusive per session. Costs are estimates from list prices.
526 − </p>
527 − ) : null}
528 − </section>
529 − )}
682 + {ordered.length > 1 ? (
683 + <p className="text-center text-[11.5px] text-fg-subtle">
684 + Swipe to compare · {carouselIndex + 1} / {ordered.length}
685 + </p>
686 + ) : null}
687 + </>
688 + ) : (
689 + <div className={cn("grid gap-3", gridClass(ordered.length, layout))}>{ordered.map((c, i) => renderColumn(c, i, { className: "animate-fade-up", style: { animationDelay: `${i * 40}ms` }, sync: true }))}</div>
690 + )}
691 +
692 + {winner ? <WinnerCard winner={winner} name={hidden ? blindLabel(Math.max(0, winnerIndex)) : winnerModel?.displayName ?? winner.modelKey.split("/").slice(1).join("/")} provider={winnerModel?.provider ?? winner.modelKey.split("/")[0]} hidden={hidden} blindIndex={Math.max(0, winnerIndex)} customCriteria={criteria.custom} showCosts={preferences.showCosts} /> : null}
693 +
694 + {allFinal && !winner ? (
695 + <p className="text-[12px] text-fg-subtle">
696 + All done. Vote on the criteria that matter to you — the model with the most criteria becomes the Arena winner and feeds your <Link href="/app/arena/scoreboard" className="text-accent underline-offset-4 hover:underline">scoreboard</Link>.
697 + {hidden ? " Voting “Best answer” reveals the models." : ""}
698 + </p>
699 + ) : null}
530 700
531 − {/* History (below on most screens) */}
532 − <ArenaHistory className="2xl:hidden" sessions={history.data?.sessions} loading={history.isLoading} activeId={session?.id ?? null} disabled={running} onLoad={loadSession} onRerun={rerunSession} />
533 − </div>
701 + {comparisonRows.length > 1 ? <ComparisonTable rows={comparisonRows} showCosts={preferences.showCosts} customCriteria={criteria.custom} /> : null}
702 + </section>
703 + )}
534 704
535 − {/* History rail on very wide screens */}
536 − <aside className="hidden 2xl:block">
537 − <div className="sticky top-0">
538 − <ArenaHistory sessions={history.data?.sessions} loading={history.isLoading} activeId={session?.id ?? null} disabled={running} onLoad={loadSession} onRerun={rerunSession} />
539 − </div>
540 − </aside>
705 + <ArenaHistory sessions={history.data?.sessions} loading={history.isLoading} activeId={session?.id ?? null} disabled={running} onOpen={openSession} onRerun={rerunSession} onDelete={deleteSession} onExport={(s, f) => exportSession(s.id, f)} onShare={(s) => setShareFor(s.id)} />
541 706 </div>
542 707 </main>
708 +
709 + {/* Composer, fixed at the bottom (the shell reserves the bottom-nav space) */}
710 + <div className="shrink-0 border-t border-border bg-bg px-3 pb-2 pt-2 sm:px-5 lg:px-6">
711 + <div className="mx-auto w-full max-w-[1680px] space-y-2">
712 + <ArenaModelPicker selected={selected} onChange={setSelected} disabled={running} />
713 + <Composer
714 + model={composerModel}
715 + busy={running}
716 + enterToSend={preferences.enterToSend}
717 + attachments={attachments}
718 + onAttachmentsChange={setAttachments}
719 + value={draft}
720 + onValueChange={(v) => {
721 + if (v === "" && keepDraft.current !== null) {
722 + // Composer clears its text after send; the Arena keeps the prompt for iteration.
723 + setDraft(keepDraft.current);
724 + keepDraft.current = null;
725 + return;
726 + }
727 + setDraft(v);
728 + }}
729 + onSend={(t) => {
730 + keepDraft.current = t;
731 + void run(t);
732 + }}
733 + onStop={stop}
734 + leftSlot={
735 + <>
736 + <ModelConfig model={sharedModel} settings={settings} onChange={setSettings} systemPrompt={systemPrompt} onSystemPromptChange={setSystemPrompt} allowSavePreset={false} />
737 + <Tooltip content={blindNext ? "Blind Arena on — identities hidden until you vote" : "Blind Arena — hide model names until you vote"}>
738 + <button type="button" aria-pressed={blindNext} disabled={running} onClick={() => setBlindNext((v) => !v)} className={cn("tap inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-[12.5px] font-medium transition-colors [&_svg]:size-3.5", blindNext ? "bg-accent-soft text-accent" : "text-fg-muted hover:bg-bg-muted hover:text-fg")}>
739 + {blindNext ? <EyeOff /> : <Eye />}
740 + <span className="hidden sm:inline">Blind</span>
741 + </button>
742 + </Tooltip>
743 + </>
744 + }
745 + placeholder={selected.length ? `Ask ${selected.length === 1 ? "this model" : `all ${selected.length} models`} the same thing…` : "Pick models, then write one prompt for all of them…"}
746 + />
747 + <div className="flex min-h-4 flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted">
748 + {runReason ? (
749 + <span className="inline-flex items-center gap-1.5">
750 + <AlertTriangle className="size-3.5 text-warning" /> {runReason}
751 + </span>
752 + ) : (
753 + <span className="hidden sm:inline">
754 + <Kbd>⌘↵</Kbd> runs all {selected.length} model{selected.length === 1 ? "" : "s"} in parallel{blindNext ? " · blind" : ""}
755 + </span>
756 + )}
757 + {systemPrompt.trim() ? <span className="text-fg-subtle">· system prompt set</span> : null}
758 + {hasImages && noVision.length ? (
759 + <span className="inline-flex items-center gap-1 text-warning">
760 + <AlertTriangle className="size-3.5" /> Images are skipped for {noVision.map((m) => m.displayName).join(", ")} (no vision).
761 + </span>
762 + ) : null}
763 + </div>
764 + </div>
765 + </div>
766 +
767 + <ActionSheet open={menuOpen} onOpenChange={setMenuOpen} title="Arena" items={menuItems} />
768 + <ArenaShareSheet sessionId={shareFor} open={Boolean(shareFor)} onOpenChange={(o) => !o && setShareFor(null)} />
543 769 </div>
544 770 );
545 771 }
@@ -548,14 +774,14 @@ function ArenaEmpty({ noProviders }: { noProviders: boolean }) {
548 774 return (
549 775 <section className="rounded-xl border border-dashed border-border px-5 py-8 sm:px-8" aria-label="About the Arena">
550 776 <div className="mx-auto max-w-2xl">
551 − <h2 className="text-lg font-semibold tracking-tight">
777 + <h2 className="text-balance text-lg font-semibold tracking-tight">
552 778 Same prompt, <span className="text-gradient">every model</span>, one screen.
553 779 </h2>
554 − <p className="mt-1.5 text-[13.5px] text-fg-muted">Pick up to four models from your connected providers, write one prompt and watch them answer live, side by side. Then compare speed, cost and quality — and record your pick.</p>
780 + <p className="mt-1.5 text-[13.5px] text-fg-muted">Pick up to four models from your connected providers, write one prompt and watch them answer live. Then compare speed, cost and quality — vote, and let the scoreboard remember.</p>
555 781 <ul className="mt-5 grid gap-3 sm:grid-cols-3">
556 − <Feature icon={<Columns3 className="size-4" />} title="Live, in parallel" text="Each model streams in its own column with reasoning, citations and a status you can trust." />
557 − <Feature icon={<Timer className="size-4" />} title="Hard numbers" text="Time to first token, total latency, tokens per second and an estimated cost for every answer." />
558 − <Feature icon={<Trophy className="size-4" />} title="Your verdict" text="Rate best overall, reasoning, coding or writing. Sessions are kept so you can reload or re-run them." />
782 + <Feature icon={<Columns3 className="size-4" />} title="Live, in parallel" text="Each model streams with reasoning, citations and live tok/s. Swipe between them on your phone." />
783 + <Feature icon={<Timer className="size-4" />} title="Hard numbers" text="Time to first token, tokens per second, in/out tokens and cost — estimated live, exact when done." />
784 + <Feature icon={<Trophy className="size-4" />} title="Your verdict" text="Vote per criterion, try Blind Arena, share a comparison, and build a personal scoreboard." />
559 785 </ul>
560 786 {noProviders ? (
561 787 <div className="mt-5 flex flex-wrap items-center gap-3 rounded-lg border border-border bg-bg-elevated px-4 py-3">
added src/components/arena/blind.tsx +49 −0
@@ -0,0 +1,49 @@
1 +"use client";
2 +import * as React from "react";
3 +import { BLIND_LETTERS } from "@/lib/arena/scoring";
4 +import { cn } from "@/lib/utils";
5 +
6 +/** Neutral letter avatar used while identities are hidden in Blind Arena. */
7 +export function BlindAvatar({ index, size = 20, className }: { index: number; size?: number; className?: string }) {
8 + const letter = BLIND_LETTERS[index] ?? String(index + 1);
9 + return (
10 + <span className={cn("inline-flex shrink-0 items-center justify-center rounded-full bg-fg font-mono font-semibold text-bg", className)} style={{ width: size, height: size, fontSize: Math.max(9, Math.round(size * 0.55)) }} aria-label={`Model ${letter}`}>
11 + {letter}
12 + </span>
13 + );
14 +}
15 +
16 +/**
17 + * Card-flip reveal: rotates the children 90° around Y, swaps content at the midpoint, rotates back.
18 + * `flipping` is driven by the parent; the content already reflects the new state when it flips back.
19 + */
20 +export function Flip({ flipping, children, className }: { flipping: boolean; children: React.ReactNode; className?: string }) {
21 + return (
22 + <span className={cn("inline-flex min-w-0 items-center transition-transform duration-150 ease-out [backface-visibility:hidden] [transform-style:preserve-3d] motion-reduce:transition-none", flipping ? "[transform:rotateY(90deg)]" : "[transform:rotateY(0deg)]", className)}>
23 + {children}
24 + </span>
25 + );
26 +}
27 +
28 +/** Drives a two-phase flip: returns `[flipping, trigger]`; `onMidpoint` fires when the card is edge-on. */
29 +export function useFlip(durationMs = 150): [boolean, (onMidpoint: () => void) => void] {
30 + const [flipping, setFlipping] = React.useState(false);
31 + const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
32 + React.useEffect(
33 + () => () => {
34 + if (timer.current) clearTimeout(timer.current);
35 + },
36 + [],
37 + );
38 + const trigger = React.useCallback(
39 + (onMidpoint: () => void) => {
40 + setFlipping(true);
41 + timer.current = setTimeout(() => {
42 + onMidpoint();
43 + setFlipping(false);
44 + }, durationMs);
45 + },
46 + [durationMs],
47 + );
48 + return [flipping, trigger];
49 +}
added src/components/arena/comparison-table.tsx +127 −0
@@ -0,0 +1,127 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Trophy } from "lucide-react";
4 +import { ProviderIcon } from "@/components/brand/provider-icon";
5 +import type { LiveMetrics } from "@/lib/arena/metrics";
6 +import { criterionById, type Criterion } from "@/lib/arena/scoring";
7 +import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";
8 +import { STATUS_META, StatusDot, formatTps } from "./metrics-strip";
9 +import { BlindAvatar } from "./blind";
10 +
11 +export interface ComparisonRow {
12 + key: string;
13 + name: string;
14 + provider: string | null;
15 + /** Blind: identity hidden, show letter avatar. */
16 + hidden?: boolean;
17 + blindIndex: number;
18 + metrics: LiveMetrics;
19 + criteriaWon: string[];
20 + isWinner: boolean;
21 +}
22 +
23 +interface Props {
24 + rows: ComparisonRow[];
25 + showCosts?: boolean;
26 + customCriteria?: Criterion[];
27 + className?: string;
28 +}
29 +
30 +type Col = { id: string; label: string; get: (r: ComparisonRow) => string; best?: (rows: ComparisonRow[]) => string | null };
31 +
32 +function bestBy(rows: ComparisonRow[], get: (r: ComparisonRow) => number | null, dir: "min" | "max"): string | null {
33 + const cands = rows.map((r) => ({ key: r.key, v: get(r) })).filter((x): x is { key: string; v: number } => typeof x.v === "number" && Number.isFinite(x.v));
34 + if (cands.length < 2) return null;
35 + cands.sort((a, b) => (dir === "min" ? a.v - b.v : b.v - a.v));
36 + return cands[0].v === cands[1].v ? null : cands[0].key;
37 +}
38 +
39 +/**
40 + * End-of-run comparison. Table from `md` up; stacked metric rows on phones (never a 4-column table on a phone).
41 + */
42 +export function ComparisonTable({ rows, showCosts = true, customCriteria = [], className }: Props) {
43 + const cols = React.useMemo<Col[]>(() => {
44 + const list: Col[] = [
45 + { id: "status", label: "Status", get: (r) => STATUS_META[r.metrics.status].label },
46 + { id: "ttft", label: "TTFT", get: (r) => formatMs(r.metrics.ttftMs), best: (rs) => bestBy(rs, (r) => r.metrics.ttftMs, "min") },
47 + { id: "total", label: "Total", get: (r) => formatMs(r.metrics.elapsedMs), best: (rs) => bestBy(rs, (r) => r.metrics.elapsedMs, "min") },
48 + { id: "tps", label: "Speed", get: (r) => formatTps(r.metrics.tokensPerSecond), best: (rs) => bestBy(rs, (r) => r.metrics.tokensPerSecond, "max") },
49 + { id: "in", label: "Input", get: (r) => formatTokens(r.metrics.inputTokens) },
50 + { id: "out", label: "Output", get: (r) => formatTokens(r.metrics.outputTokens) },
51 + ];
52 + if (showCosts) list.push({ id: "cost", label: "Cost", get: (r) => (r.metrics.costUsd === null ? "—" : `${r.metrics.exact ? "" : "≈ "}${formatUsd(r.metrics.costUsd, { precise: r.metrics.costUsd < 0.01 })}`), best: (rs) => bestBy(rs, (r) => r.metrics.costUsd, "min") });
53 + list.push({ id: "votes", label: "Criteria won", get: (r) => (r.criteriaWon.length ? r.criteriaWon.map((id) => criterionById(id, customCriteria).short).join(", ") : "—") });
54 + return list;
55 + }, [showCosts, customCriteria]);
56 + const bests = React.useMemo(() => Object.fromEntries(cols.map((c) => [c.id, c.best ? c.best(rows) : null])), [cols, rows]);
57 +
58 + if (!rows.length) return null;
59 + return (
60 + <section className={cn("panel p-3 sm:p-4", className)} aria-label="Comparison">
61 + <h3 className="text-[13px] font-semibold tracking-tight">Comparison</h3>
62 + <p className="mt-0.5 text-[12px] text-fg-muted">Best value per column is highlighted. Costs are estimates from list prices unless the provider reports them.</p>
63 +
64 + {/* Phone: metric rows, models inline */}
65 + <dl className="mt-3 space-y-2.5 md:hidden">
66 + {cols.map((c) => (
67 + <div key={c.id} className="border-t border-hairline pt-2 first:border-t-0 first:pt-0">
68 + <dt className="text-[10.5px] uppercase tracking-wide text-fg-subtle">{c.label}</dt>
69 + <dd className="mt-1 grid grid-cols-2 gap-x-3 gap-y-1">
70 + {rows.map((r) => (
71 + <div key={r.key} className="flex min-w-0 items-center gap-1.5 text-[12px] tabular-nums">
72 + <Identity row={r} size={12} />
73 + <span className={cn("truncate", bests[c.id] === r.key ? "font-semibold text-success" : "text-fg")}>{c.get(r)}</span>
74 + </div>
75 + ))}
76 + </dd>
77 + </div>
78 + ))}
79 + </dl>
80 +
81 + {/* Desktop: table */}
82 + <div className="mt-3 hidden overflow-x-auto md:block">
83 + <table className="w-full text-[12.5px] tabular-nums">
84 + <thead>
85 + <tr className="text-left text-[10.5px] uppercase tracking-wide text-fg-subtle">
86 + <th className="pb-2 pr-3 font-medium">Model</th>
87 + {cols.map((c) => (
88 + <th key={c.id} className="pb-2 pr-3 font-medium">
89 + {c.label}
90 + </th>
91 + ))}
92 + </tr>
93 + </thead>
94 + <tbody>
95 + {rows.map((r) => (
96 + <tr key={r.key} className="border-t border-hairline">
97 + <td className="py-2 pr-3">
98 + <span className="flex min-w-0 items-center gap-2 font-medium">
99 + <Identity row={r} size={14} />
100 + <span className="truncate">{r.name}</span>
101 + {r.isWinner ? <Trophy className="size-3.5 shrink-0 text-accent" aria-label="Arena winner" /> : null}
102 + </span>
103 + </td>
104 + {cols.map((c) => (
105 + <td key={c.id} className={cn("py-2 pr-3", bests[c.id] === r.key ? "font-semibold text-success" : "text-fg")}>
106 + {c.id === "status" ? (
107 + <span className="inline-flex items-center gap-1.5">
108 + <StatusDot status={r.metrics.status} /> {c.get(r)}
109 + </span>
110 + ) : (
111 + c.get(r)
112 + )}
113 + </td>
114 + ))}
115 + </tr>
116 + ))}
117 + </tbody>
118 + </table>
119 + </div>
120 + </section>
121 + );
122 +}
123 +
124 +function Identity({ row, size }: { row: ComparisonRow; size: number }) {
125 + if (row.hidden) return <BlindAvatar index={row.blindIndex} size={size + 4} />;
126 + return <ProviderIcon provider={row.provider} size={size} />;
127 +}
added src/components/arena/download.ts +28 −0
@@ -0,0 +1,28 @@
1 +"use client";
2 +import { ClientApiError } from "@/lib/client/api";
3 +
4 +/** POST /api/arena/:id/export?format= and save the file through a temporary object URL. */
5 +export async function downloadArenaExport(sessionId: string, format: "markdown" | "json"): Promise<string> {
6 + const res = await fetch(`/api/arena/${encodeURIComponent(sessionId)}/export?format=${format}`, { method: "POST", credentials: "same-origin" });
7 + if (!res.ok) {
8 + let err: { error?: { code?: string; message?: string } } = {};
9 + try {
10 + err = await res.json();
11 + } catch {
12 + /* ignore */
13 + }
14 + throw new ClientApiError(res.status, err.error?.code ?? "HTTP_ERROR", err.error?.message ?? `Export failed (${res.status})`);
15 + }
16 + const blob = await res.blob();
17 + const cd = res.headers.get("Content-Disposition") ?? "";
18 + const filename = res.headers.get("X-Filename") ?? /filename="([^"]+)"/.exec(cd)?.[1] ?? `arena.${format === "json" ? "json" : "md"}`;
19 + const url = URL.createObjectURL(blob);
20 + const a = document.createElement("a");
21 + a.href = url;
22 + a.download = filename;
23 + document.body.appendChild(a);
24 + a.click();
25 + a.remove();
26 + setTimeout(() => URL.revokeObjectURL(url), 2000);
27 + return filename;
28 +}
added src/components/arena/metrics-strip.tsx +73 −0
@@ -0,0 +1,73 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Coins, Gauge, Timer, Zap } from "lucide-react";
4 +import type { LiveMetrics } from "@/lib/arena/metrics";
5 +import type { ColumnStatus } from "./types";
6 +import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";
7 +
8 +export const STATUS_META: Record<ColumnStatus, { label: string; dot: string; pulse: boolean }> = {
9 + idle: { label: "Ready", dot: "bg-fg-subtle", pulse: false },
10 + waiting: { label: "Queued", dot: "bg-fg-subtle", pulse: true },
11 + thinking: { label: "Thinking", dot: "bg-accent", pulse: true },
12 + streaming: { label: "Streaming", dot: "bg-info", pulse: true },
13 + done: { label: "Done", dot: "bg-success", pulse: false },
14 + error: { label: "Error", dot: "bg-danger", pulse: false },
15 + stopped: { label: "Stopped", dot: "bg-warning", pulse: false },
16 +};
17 +
18 +export function StatusDot({ status, className }: { status: ColumnStatus; className?: string }) {
19 + const s = STATUS_META[status];
20 + return <span className={cn("inline-block size-2 shrink-0 rounded-full", s.dot, s.pulse && "animate-pulse-soft", className)} aria-hidden />;
21 +}
22 +
23 +/** A shared clock: re-renders the caller every `ms` while `active`. Returns `Date.now()` snapshots. */
24 +export function useTick(active: boolean, ms = 500): number {
25 + const [now, setNow] = React.useState(() => Date.now());
26 + React.useEffect(() => {
27 + if (!active) return;
28 + // eslint-disable-next-line react-hooks/set-state-in-effect -- clock start
29 + setNow(Date.now());
30 + const t = setInterval(() => setNow(Date.now()), ms);
31 + return () => clearInterval(t);
32 + }, [active, ms]);
33 + return now;
34 +}
35 +
36 +export function formatTps(v: number | null): string {
37 + return v ? `${v} tok/s` : "—";
38 +}
39 +
40 +/**
41 + * Compact metrics strip shown under each Arena response: status, elapsed/TTFT, tok/s, tokens, cost.
42 + * Estimates (while streaming) are prefixed with ≈; exact server numbers are not.
43 + */
44 +export function MetricsStrip({ metrics: m, showCosts = true, fastest, cheapest, className }: { metrics: LiveMetrics; showCosts?: boolean; fastest?: boolean; cheapest?: boolean; className?: string }) {
45 + const approx = m.exact ? "" : "≈ ";
46 + const live = m.status === "waiting" || m.status === "thinking" || m.status === "streaming";
47 + const tokens = m.inputTokens === null && m.outputTokens === null ? "—" : `${formatTokens(m.inputTokens ?? 0)} → ${formatTokens(m.outputTokens ?? 0)}`;
48 + return (
49 + <dl className={cn("grid grid-cols-3 gap-x-3 gap-y-1.5 text-[11.5px] tabular-nums sm:grid-cols-5", className)} aria-live={live ? "polite" : undefined}>
50 + <Metric icon={<Timer />} label={m.ttftMs !== null ? "TTFT" : "Elapsed"} value={m.ttftMs !== null ? formatMs(m.ttftMs) : formatMs(m.elapsedMs)} highlight={fastest} hint={m.ttftMs !== null && live ? `${formatMs(m.elapsedMs)} total` : m.ttftMs !== null && !live ? `${formatMs(m.elapsedMs)} total` : undefined} />
51 + <Metric icon={<Zap />} label="Speed" value={formatTps(m.tokensPerSecond)} />
52 + <Metric icon={<Gauge />} label="Tokens" value={`${m.exact || tokens === "—" ? "" : approx}${tokens}`} hint={m.reasoningTokens ? `+${formatTokens(m.reasoningTokens)} reasoning` : m.cachedTokens ? `${formatTokens(m.cachedTokens)} cached` : undefined} />
53 + {showCosts ? <Metric icon={<Coins />} label="Cost" value={m.costUsd === null ? "—" : `${approx}${formatUsd(m.costUsd, { precise: m.costUsd < 0.01 })}`} highlight={cheapest} title={m.exact ? "From provider usage and list price" : "Estimated from characters streamed so far"} /> : null}
54 + <Metric label="Status" value={STATUS_META[m.status].label} dot={<StatusDot status={m.status} />} className="hidden sm:block" />
55 + </dl>
56 + );
57 +}
58 +
59 +function Metric({ icon, label, value, hint, highlight, title, dot, className }: { icon?: React.ReactNode; label: string; value: string; hint?: string; highlight?: boolean; title?: string; dot?: React.ReactNode; className?: string }) {
60 + return (
61 + <div className={cn("min-w-0", className)} title={title}>
62 + <dt className="flex items-center gap-1 text-[10.5px] uppercase tracking-wide text-fg-subtle [&_svg]:size-3">
63 + {icon}
64 + {label}
65 + </dt>
66 + <dd className={cn("flex items-center gap-1.5 truncate font-medium", highlight ? "text-success" : "text-fg")}>
67 + {dot}
68 + {value}
69 + </dd>
70 + {hint ? <dd className="truncate text-[10.5px] text-fg-subtle">{hint}</dd> : null}
71 + </div>
72 + );
73 +}
modified src/components/arena/model-picker.tsx +14 −9
@@ -15,7 +15,8 @@ const PRESETS: { id: PresetId; label: string; icon: React.ReactNode; hint: strin
15 15 { id: "reasoning", label: "Reasoning", icon: <Brain className="size-3.5" />, hint: "Models with extended thinking" },
16 16 ];
17 17
18 −export function ArenaModelPicker({ selected, onChange, disabled }: { selected: string[]; onChange: (keys: string[]) => void; disabled?: boolean }) {
18 +/** Selected-model chips + "Add model" + quick presets. One scrollable row on phones, wraps from `md`. */
19 +export function ArenaModelPicker({ selected, onChange, disabled, className }: { selected: string[]; onChange: (keys: string[]) => void; disabled?: boolean; className?: string }) {
19 20 const { models, modelsByKey, connectedProviders } = useApp();
20 21 const set = React.useMemo(() => new Set(selected), [selected]);
21 22
@@ -32,28 +33,32 @@ export function ArenaModelPicker({ selected, onChange, disabled }: { selected: s
32 33 };
33 34
34 35 return (
35 − <div className={cn("flex flex-wrap items-center gap-2", disabled && "pointer-events-none opacity-60")} aria-disabled={disabled}>
36 + <div className={cn("flex items-center gap-2 overflow-x-auto scrollbar-none md:flex-wrap", disabled && "pointer-events-none opacity-60", className)} aria-disabled={disabled} data-no-edge-swipe>
36 37 {selected.map((key, i) => {
37 38 const m = modelsByKey.get(key);
38 39 return (
39 − <span key={key} className="group inline-flex h-9 max-w-[min(100%,260px)] items-center gap-2 rounded-lg border border-border bg-bg-elevated pl-2.5 pr-1 text-[13px] shadow-xs animate-fade-up" style={{ animationDelay: `${i * 30}ms` }}>
40 + <span key={key} className="group inline-flex h-9 max-w-[220px] shrink-0 items-center gap-2 rounded-lg border border-border bg-bg-elevated pl-2.5 pr-1 text-[13px] shadow-xs animate-fade-up" style={{ animationDelay: `${i * 30}ms` }}>
40 41 <ProviderIcon provider={m?.provider ?? key.split("/")[0]} size={14} />
41 42 <span className="truncate font-medium">{m?.displayName ?? key}</span>
42 − <button type="button" onClick={() => toggle(key)} className="rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-danger" aria-label={`Remove ${m?.displayName ?? key}`}>
43 + <button type="button" onClick={() => toggle(key)} className="tap rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-danger" aria-label={`Remove ${m?.displayName ?? key}`}>
43 44 <X className="size-3.5" />
44 45 </button>
45 46 </span>
46 47 );
47 48 })}
48 − {selected.length < MAX_MODELS ? <ModelSelector value={null} multiple selected={set} onToggle={toggle} buttonLabel={selected.length ? "Add model" : "Choose models"} size="md" /> : null}
49 − <span className="text-[12px] tabular-nums text-fg-subtle">
49 + {selected.length < MAX_MODELS ? (
50 + <span className="shrink-0">
51 + <ModelSelector value={null} multiple selected={set} onToggle={toggle} buttonLabel={selected.length ? "Add model" : "Choose models"} size="md" />
52 + </span>
53 + ) : null}
54 + <span className="shrink-0 text-[12px] tabular-nums text-fg-subtle">
50 55 {selected.length}/{MAX_MODELS}
51 56 </span>
52 − <span className="hidden h-5 w-px bg-border sm:block" aria-hidden />
53 − <div className="flex items-center gap-1" role="group" aria-label="Quick presets">
57 + <span className="hidden h-5 w-px shrink-0 bg-border sm:block" aria-hidden />
58 + <div className="flex shrink-0 items-center gap-1" role="group" aria-label="Quick presets">
54 59 {PRESETS.map((p) => (
55 60 <Tooltip key={p.id} content={p.hint}>
56 − <button type="button" onClick={() => applyPreset(p.id)} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium text-fg-muted transition-colors hover:bg-bg-muted hover:text-fg">
61 + <button type="button" onClick={() => applyPreset(p.id)} className="tap inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium text-fg-muted transition-colors hover:bg-bg-muted hover:text-fg">
57 62 {p.icon}
58 63 {p.label}
59 64 </button>
added src/components/arena/model-tabs.tsx +67 −0
@@ -0,0 +1,67 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Trophy } from "lucide-react";
4 +import { ProviderIcon } from "@/components/brand/provider-icon";
5 +import type { ColumnStatus } from "./types";
6 +import { StatusDot } from "./metrics-strip";
7 +import { BlindAvatar } from "./blind";
8 +import { cn } from "@/lib/utils";
9 +
10 +export interface ModelTabItem {
11 + key: string;
12 + name: string;
13 + provider: string | null;
14 + status: ColumnStatus;
15 + tokensPerSecond: number | null;
16 + isWinner?: boolean;
17 + /** Blind: identity hidden. */
18 + hidden?: boolean;
19 + blindIndex: number;
20 +}
21 +
22 +/**
23 + * Sticky model tab bar for the phone carousel (Segmented-like): provider icon (or blind letter),
24 + * status dot, name and live tok/s. Selecting a tab scrolls the carousel; swiping updates the tab.
25 + */
26 +export function ModelTabs({ items, active, onSelect, className }: { items: ModelTabItem[]; active: number; onSelect: (index: number) => void; className?: string }) {
27 + const ref = React.useRef<HTMLDivElement>(null);
28 + // Keep the active tab in view while swiping.
29 + React.useEffect(() => {
30 + const el = ref.current?.children[active] as HTMLElement | undefined;
31 + el?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
32 + }, [active]);
33 + const onKey = (e: React.KeyboardEvent) => {
34 + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
35 + e.preventDefault();
36 + const next = (active + (e.key === "ArrowLeft" ? -1 : 1) + items.length) % items.length;
37 + onSelect(next);
38 + (ref.current?.children[next] as HTMLElement | undefined)?.focus();
39 + };
40 + return (
41 + <div ref={ref} role="tablist" aria-label="Models" onKeyDown={onKey} className={cn("flex h-11 w-full items-center gap-0.5 overflow-x-auto rounded-lg bg-bg-muted p-1 scrollbar-none", className)}>
42 + {items.map((it, i) => {
43 + const on = i === active;
44 + return (
45 + <button
46 + key={it.key}
47 + role="tab"
48 + type="button"
49 + aria-selected={on}
50 + tabIndex={on ? 0 : -1}
51 + onClick={() => onSelect(i)}
52 + className={cn(
53 + "inline-flex h-9 min-w-0 flex-1 shrink-0 basis-[44%] items-center justify-center gap-1.5 rounded-md px-2 text-[12.5px] font-medium transition-[background-color,color,box-shadow] duration-150 sm:basis-auto",
54 + on ? "bg-bg-elevated text-fg shadow-xs" : "text-fg-muted",
55 + )}
56 + >
57 + {it.hidden ? <BlindAvatar index={it.blindIndex} size={16} /> : <ProviderIcon provider={it.provider} size={13} />}
58 + <span className="truncate">{it.name}</span>
59 + <StatusDot status={it.status} />
60 + {it.tokensPerSecond ? <span className="hidden shrink-0 text-[10.5px] tabular-nums text-fg-subtle min-[400px]:inline">{it.tokensPerSecond}/s</span> : null}
61 + {it.isWinner ? <Trophy className="size-3 shrink-0 text-accent" aria-label="Winner" /> : null}
62 + </button>
63 + );
64 + })}
65 + </div>
66 + );
67 +}
added src/components/arena/scoreboard-view.tsx +182 −0
@@ -0,0 +1,182 @@
1 +"use client";
2 +import * as React from "react";
3 +import Link from "next/link";
4 +import { ArrowLeft, Coins, Info, Menu, Swords, Trophy } from "lucide-react";
5 +import { useApp } from "@/components/app/store";
6 +import { ProviderIcon } from "@/components/brand/provider-icon";
7 +import { Button } from "@/components/ui/button";
8 +import { ChipRow } from "@/components/ui/segmented";
9 +import { Skeleton } from "@/components/ui/misc";
10 +import { Tooltip } from "@/components/ui/tooltip";
11 +import { useApi } from "@/lib/client/api";
12 +import { PROVIDERS } from "@/lib/client/providers";
13 +import { CATEGORIES, criterionById, type ScoreboardRow, type TaskCategory } from "@/lib/arena/scoring";
14 +import { cn, formatMs, formatUsd } from "@/lib/utils";
15 +
16 +type Filter = "all" | TaskCategory | "value";
17 +
18 +const FILTERS: { value: Filter; label: string; icon?: React.ReactNode }[] = [{ value: "all", label: "All" }, ...CATEGORIES.map((c) => ({ value: c.value as Filter, label: c.label })), { value: "value", label: "Cost efficiency", icon: <Coins /> }];
19 +
20 +interface ScoreboardResponse {
21 + rows: ScoreboardRow[];
22 + sessions: number;
23 + votes: number;
24 +}
25 +
26 +/** Personal Arena scoreboard: win rate per model from your own votes. Mobile-first ranking list. */
27 +export function ScoreboardView() {
28 + const { modelsByKey, setSidebarOpen, preferences } = useApp();
29 + const [filter, setFilter] = React.useState<Filter>("all");
30 + const query = filter === "all" ? "" : filter === "value" ? "?criterion=value" : `?category=${filter}`;
31 + const { data, isLoading, error } = useApi<ScoreboardResponse>(`/api/arena/scoreboard${query}`);
32 + const rows = React.useMemo(() => (data?.rows ?? []).filter((r) => r.sessions > 0), [data]);
33 + const ranked = React.useMemo(() => rows.filter((r) => r.decided > 0), [rows]);
34 + const unranked = React.useMemo(() => rows.filter((r) => r.decided === 0), [rows]);
35 + const nameOf = (k: string) => modelsByKey.get(k)?.displayName ?? k.split("/").slice(1).join("/");
36 +
37 + return (
38 + <div className="flex h-full min-h-0 flex-col">
39 + <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 sm:px-4">
40 + <Button variant="ghost" size="icon-sm" className="md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">
41 + <Menu />
42 + </Button>
43 + <Button asChild variant="ghost" size="icon-sm" aria-label="Back to Arena">
44 + <Link href="/app/arena">
45 + <ArrowLeft />
46 + </Link>
47 + </Button>
48 + <span className="flex size-7 items-center justify-center rounded-md bg-accent-soft text-accent">
49 + <Trophy className="size-4" />
50 + </span>
51 + <div className="min-w-0 leading-tight">
52 + <h1 className="text-[14px] font-semibold tracking-tight">Scoreboard</h1>
53 + <p className="hidden truncate text-[11.5px] text-fg-muted sm:block">Which models win your comparisons.</p>
54 + </div>
55 + <Button asChild variant="outline" size="sm" className="ml-auto">
56 + <Link href="/app/arena">
57 + <Swords /> <span className="hidden sm:inline">New comparison</span>
58 + <span className="sm:hidden">Arena</span>
59 + </Link>
60 + </Button>
61 + </header>
62 +
63 + <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
64 + <div className="mx-auto w-full max-w-3xl px-3 py-4 sm:px-5">
65 + <ChipRow value={filter} onChange={setFilter} options={FILTERS} className="-mx-3 px-3 pb-1 sm:mx-0 sm:px-0" />
66 + <p className="mt-2 flex items-start gap-1.5 text-[12px] text-fg-muted">
67 + <Info className="mt-0.5 size-3.5 shrink-0" />
68 + <span>
69 + {filter === "value" ? "Ranked by “Best value” votes only." : filter === "all" ? "Ranked by the share of decided comparisons each model won (most criteria; ties → fastest)." : `Only comparisons classified as ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()} prompts.`}
70 + {data ? ` ${data.sessions} session${data.sessions === 1 ? "" : "s"} · ${data.votes} vote${data.votes === 1 ? "" : "s"}.` : ""}
71 + </span>
72 + </p>
73 +
74 + {error ? (
75 + <p className="mt-6 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">Could not load the scoreboard: {(error as Error).message}</p>
76 + ) : isLoading && !data ? (
77 + <ul className="mt-4 space-y-2">
78 + {[0, 1, 2, 3].map((i) => (
79 + <Skeleton key={i} className="h-[76px]" />
80 + ))}
81 + </ul>
82 + ) : !ranked.length ? (
83 + <EmptyScoreboard hasSessions={rows.length > 0} filtered={filter !== "all"} />
84 + ) : (
85 + <ol className="mt-4 space-y-2" aria-label="Ranking">
86 + {ranked.map((r, i) => (
87 + <RankRow key={r.modelKey} rank={i + 1} row={r} name={nameOf(r.modelKey)} showCosts={preferences.showCosts} filter={filter} />
88 + ))}
89 + </ol>
90 + )}
91 +
92 + {unranked.length && ranked.length ? (
93 + <details className="mt-5 text-[12.5px] text-fg-muted">
94 + <summary className="cursor-pointer select-none">
95 + {unranked.length} model{unranked.length === 1 ? "" : "s"} without a decided comparison
96 + </summary>
97 + <ul className="mt-2 flex flex-wrap gap-1.5">
98 + {unranked.map((r) => (
99 + <li key={r.modelKey} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1">
100 + <ProviderIcon provider={r.provider} size={12} /> {nameOf(r.modelKey)} <span className="text-fg-subtle">· {r.sessions}</span>
101 + </li>
102 + ))}
103 + </ul>
104 + </details>
105 + ) : null}
106 + </div>
107 + </main>
108 + </div>
109 + );
110 +}
111 +
112 +function RankRow({ rank, row: r, name, showCosts, filter }: { rank: number; row: ScoreboardRow; name: string; showCosts: boolean; filter: Filter }) {
113 + const pct = Math.round(r.winRate * 100);
114 + const top = rank === 1;
115 + const crit = Object.entries(r.criteria)
116 + .sort((a, b) => b[1] - a[1])
117 + .slice(0, 3);
118 + return (
119 + <li className={cn("rounded-xl border bg-bg-elevated p-3 sm:p-3.5", top ? "border-accent/50 shadow-glow" : "border-border")}>
120 + <div className="flex items-center gap-3">
121 + <span className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg text-[13px] font-semibold tabular-nums", top ? "bg-accent text-accent-fg" : "bg-bg-muted text-fg-muted")} aria-label={`Rank ${rank}`}>
122 + {rank}
123 + </span>
124 + <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-subtle">
125 + <ProviderIcon provider={r.provider} size={16} />
126 + </span>
127 + <div className="min-w-0 flex-1">
128 + <div className="flex items-baseline gap-2">
129 + <h3 className="truncate text-[14px] font-semibold tracking-tight">{name}</h3>
130 + <span className="truncate text-[11.5px] text-fg-subtle">{PROVIDERS[r.provider as keyof typeof PROVIDERS]?.shortName ?? r.provider}</span>
131 + </div>
132 + <div className="mt-1.5 flex items-center gap-2">
133 + <div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Win rate">
134 + <div className={cn("h-full rounded-full transition-[width] duration-500", top ? "bg-accent" : "bg-fg/70")} style={{ width: `${Math.max(2, pct)}%` }} />
135 + </div>
136 + <span className="w-10 shrink-0 text-right text-[13px] font-semibold tabular-nums">{pct}%</span>
137 + </div>
138 + </div>
139 + </div>
140 + <dl className="mt-2.5 grid grid-cols-3 gap-2 text-[11.5px] tabular-nums sm:grid-cols-4">
141 + <Stat label={filter === "value" ? "Value wins" : "Wins"} value={`${r.wins} / ${r.decided}`} hint={`${r.sessions} session${r.sessions === 1 ? "" : "s"}`} />
142 + <Stat label="Votes" value={String(r.votes)} hint={crit.length ? crit.map(([id, n]) => `${criterionById(id).short} ${n}`).join(" · ") : undefined} />
143 + <Stat label="Avg TTFT" value={formatMs(r.avgTtftMs)} />
144 + {showCosts ? <Stat label="Avg cost" value={r.avgCostUsd === null ? "—" : formatUsd(r.avgCostUsd, { precise: r.avgCostUsd < 0.01 })} hint="per response" className="hidden sm:block" /> : null}
145 + </dl>
146 + </li>
147 + );
148 +}
149 +
150 +function Stat({ label, value, hint, className }: { label: string; value: string; hint?: string; className?: string }) {
151 + return (
152 + <div className={cn("min-w-0", className)}>
153 + <dt className="text-[10.5px] uppercase tracking-wide text-fg-subtle">{label}</dt>
154 + <dd className="truncate font-medium text-fg">{value}</dd>
155 + {hint ? (
156 + <Tooltip content={hint}>
157 + <dd className="truncate text-[10.5px] text-fg-subtle">{hint}</dd>
158 + </Tooltip>
159 + ) : null}
160 + </div>
161 + );
162 +}
163 +
164 +function EmptyScoreboard({ hasSessions, filtered }: { hasSessions: boolean; filtered: boolean }) {
165 + return (
166 + <section className="mt-6 rounded-xl border border-dashed border-border px-5 py-8 text-center sm:px-8">
167 + <span className="mx-auto flex size-10 items-center justify-center rounded-lg bg-bg-muted text-fg-muted">
168 + <Trophy className="size-5" />
169 + </span>
170 + <h2 className="mt-3 text-balance text-[16px] font-semibold tracking-tight">{filtered ? "No decided comparison in this filter yet" : hasSessions ? "Vote to build your ranking" : "Your ranking starts with a comparison"}</h2>
171 + <p className="mx-auto mt-1.5 max-w-md text-[13px] leading-5 text-fg-muted">
172 + Run the same prompt through several models in the Arena, then vote on criteria such as Best answer, Most accurate or Best value. Each decided comparison counts as a win for the model that took the most criteria (ties go to the fastest). The win rate is wins over
173 + the comparisons a model took part in that received a vote.
174 + </p>
175 + <Button asChild variant="accent" size="sm" className="mt-4">
176 + <Link href="/app/arena">
177 + <Swords /> Open the Arena
178 + </Link>
179 + </Button>
180 + </section>
181 + );
182 +}
added src/components/arena/share-sheet.tsx +124 −0
@@ -0,0 +1,124 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Check, Copy, ExternalLink, Eye, Link2, ShieldAlert } from "lucide-react";
4 +import { ResponsiveDialog } from "@/components/ui/sheet";
5 +import { Button } from "@/components/ui/button";
6 +import { Spinner } from "@/components/ui/misc";
7 +import { toast } from "@/components/ui/toast";
8 +import { api, useApi } from "@/lib/client/api";
9 +import { useCopy } from "@/lib/client/hooks";
10 +import { formatRelative } from "@/lib/utils";
11 +
12 +interface ShareStatus {
13 + share: { id: string; createdAt: string; viewCount: number } | null;
14 +}
15 +
16 +/**
17 + * Public link for an Arena session. Shows a privacy warning before the first link is created; afterwards the
18 + * URL, view count, refresh and revoke. Bottom sheet on phones, dialog on desktop.
19 + */
20 +export function ArenaShareSheet({ sessionId, open, onOpenChange }: { sessionId: string | null; open: boolean; onOpenChange: (o: boolean) => void }) {
21 + const status = useApi<ShareStatus>(open && sessionId ? `/api/arena/${sessionId}/share` : null);
22 + const [busy, setBusy] = React.useState(false);
23 + const [copied, copy] = useCopy();
24 + const share = status.data?.share ?? null;
25 + const url = share ? `${typeof window !== "undefined" ? window.location.origin : ""}/share/arena/${share.id}` : null;
26 +
27 + const create = async () => {
28 + if (!sessionId) return;
29 + setBusy(true);
30 + try {
31 + const { share: created } = await api<{ share: ShareStatus["share"] }>(`/api/arena/${sessionId}/share`, { method: "POST" });
32 + await status.mutate({ share: created }, { revalidate: false });
33 + toast.success(share ? "Snapshot refreshed" : "Public link created", "Anyone with the link can read this comparison.");
34 + } catch (e) {
35 + toast.error("Could not create the link", (e as Error).message);
36 + } finally {
37 + setBusy(false);
38 + }
39 + };
40 + const revoke = async () => {
41 + if (!sessionId) return;
42 + setBusy(true);
43 + try {
44 + await api(`/api/arena/${sessionId}/share`, { method: "DELETE" });
45 + await status.mutate({ share: null }, { revalidate: false });
46 + toast.success("Link revoked", "The public page now returns 404.");
47 + } catch (e) {
48 + toast.error("Could not revoke", (e as Error).message);
49 + } finally {
50 + setBusy(false);
51 + }
52 + };
53 +
54 + return (
55 + <ResponsiveDialog
56 + open={open}
57 + onOpenChange={onOpenChange}
58 + title="Share this comparison"
59 + description="A frozen public snapshot — prompt, system prompt, responses, metrics and votes."
60 + size="sm"
61 + footer={
62 + share ? (
63 + <div className="flex items-center gap-2">
64 + <Button variant="danger-soft" size="sm" onClick={revoke} loading={busy}>
65 + Revoke link
66 + </Button>
67 + <Button variant="ghost" size="sm" onClick={create} disabled={busy} className="ml-auto">
68 + Refresh snapshot
69 + </Button>
70 + </div>
71 + ) : (
72 + <div className="flex items-center justify-end gap-2">
73 + <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
74 + Cancel
75 + </Button>
76 + <Button variant="accent" size="sm" onClick={create} loading={busy} disabled={status.isLoading}>
77 + <Link2 /> Create public link
78 + </Button>
79 + </div>
80 + )
81 + }
82 + >
83 + {status.isLoading && !status.data ? (
84 + <div className="flex items-center gap-2 py-4 text-[13px] text-fg-muted">
85 + <Spinner /> Checking existing links…
86 + </div>
87 + ) : share && url ? (
88 + <div className="space-y-3 py-1">
89 + <div className="flex items-center gap-2 rounded-lg border border-border bg-bg-subtle px-3 py-2">
90 + <Link2 className="size-4 shrink-0 text-fg-subtle" />
91 + <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]">{url}</span>
92 + <Button variant="ghost" size="icon-sm" aria-label="Copy link" onClick={() => copy(url)}>
93 + {copied ? <Check className="text-success" /> : <Copy />}
94 + </Button>
95 + <Button asChild variant="ghost" size="icon-sm" aria-label="Open link">
96 + <a href={url} target="_blank" rel="noopener noreferrer">
97 + <ExternalLink />
98 + </a>
99 + </Button>
100 + </div>
101 + <p className="flex items-center gap-2 text-[12.5px] text-fg-muted">
102 + <Eye className="size-3.5" /> {share.viewCount} view{share.viewCount === 1 ? "" : "s"} · created {formatRelative(share.createdAt)}
103 + </p>
104 + <p className="text-[12px] text-fg-subtle">Votes cast after sharing are not reflected until you refresh the snapshot.</p>
105 + </div>
106 + ) : (
107 + <div className="space-y-3 py-1">
108 + <div className="flex gap-3 rounded-lg border border-warning/40 bg-warning-soft px-3 py-2.5 text-[13px] text-fg">
109 + <ShieldAlert className="mt-0.5 size-4 shrink-0 text-warning" />
110 + <div className="space-y-1">
111 + <p className="font-medium">Before you share</p>
112 + <ul className="list-disc space-y-0.5 pl-4 text-[12.5px] text-fg-muted">
113 + <li>The prompt, system prompt and every response are published as-is — check for personal data or secrets.</li>
114 + <li>Attachments are never published; only their count is shown.</li>
115 + <li>Model names are revealed (Blind Arena sessions are marked as blind).</li>
116 + <li>The page is public but not indexed; anyone with the link can read it. You can revoke it anytime.</li>
117 + </ul>
118 + </div>
119 + </div>
120 + </div>
121 + )}
122 + </ResponsiveDialog>
123 + );
124 +}
added src/components/arena/shared-arena-view.tsx +128 −0
@@ -0,0 +1,128 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Brain, EyeOff, Trophy, X } from "lucide-react";
4 +import { ProviderIcon } from "@/components/brand/provider-icon";
5 +import { Badge } from "@/components/ui/badge";
6 +import { SimpleMarkdown } from "@/components/markdown/simple-markdown";
7 +import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks";
8 +import type { ArenaShareSnapshot } from "@/lib/arena/export";
9 +import type { LiveMetrics } from "@/lib/arena/metrics";
10 +import { providerName } from "@/lib/client/providers";
11 +import { cn } from "@/lib/utils";
12 +import { ModelTabs } from "./model-tabs";
13 +import { MetricsStrip } from "./metrics-strip";
14 +import { ComparisonTable } from "./comparison-table";
15 +import { WinnerCard } from "./winner-card";
16 +import type { ColumnStatus } from "./types";
17 +
18 +function statusOf(s: string): ColumnStatus {
19 + return s === "complete" ? "done" : s === "error" ? "error" : "stopped";
20 +}
21 +
22 +function metricsOf(r: ArenaShareSnapshot["responses"][number]): LiveMetrics {
23 + const out = r.usage?.outputTokens ?? null;
24 + const gen = typeof r.latencyMs === "number" ? Math.max(1, r.latencyMs - (r.ttftMs ?? 0)) : null;
25 + return { status: statusOf(r.status), elapsedMs: r.latencyMs ?? 0, ttftMs: r.ttftMs, inputTokens: r.usage?.inputTokens ?? null, outputTokens: out, reasoningTokens: r.usage?.reasoningTokens ?? null, cachedTokens: null, tokensPerSecond: out && gen ? Math.round((out / gen) * 1000) : null, costUsd: r.costUsd, exact: true };
26 +}
27 +
28 +const GRID: Record<number, string> = { 1: "md:grid-cols-1", 2: "md:grid-cols-2", 3: "md:grid-cols-2 xl:grid-cols-3", 4: "md:grid-cols-2" };
29 +
30 +/** Public, read-only rendering of a shared Arena snapshot: swipe carousel on phones, grid on desktop. */
31 +export function SharedArenaView({ snapshot: s }: { snapshot: ArenaShareSnapshot }) {
32 + const isMobile = useIsMobile();
33 + const responses = s.responses;
34 + const { ref, index, scrollTo } = useSnapCarousel<HTMLDivElement>(responses.length);
35 + const winnerKey = s.winner?.responseId ?? null;
36 + const metrics = React.useMemo(() => responses.map(metricsOf), [responses]);
37 +
38 + const tabs = responses.map((r, i) => ({ key: r.id, name: r.displayName, provider: r.provider, status: statusOf(r.status), tokensPerSecond: metrics[i].tokensPerSecond, isWinner: r.id === winnerKey, blindIndex: i }));
39 + const rows = responses.map((r, i) => ({ key: r.id, name: r.displayName, provider: r.provider, blindIndex: i, metrics: metrics[i], criteriaWon: r.criteriaWon, isWinner: r.id === winnerKey }));
40 +
41 + return (
42 + <div className="space-y-4">
43 + {isMobile ? (
44 + <>
45 + <div className="sticky top-14 z-30 -mx-4 bg-bg/95 px-4 py-2 backdrop-blur-sm">
46 + <ModelTabs items={tabs} active={index} onSelect={(i) => scrollTo(i)} />
47 + </div>
48 + <div ref={ref} className="snap-row" aria-roledescription="carousel" data-no-edge-swipe>
49 + {responses.map((r, i) => (
50 + <div key={r.id} className="px-0.5">
51 + <ResponseCard r={r} metrics={metrics[i]} isWinner={r.id === winnerKey} />
52 + </div>
53 + ))}
54 + </div>
55 + <p className="text-center text-[11.5px] text-fg-subtle">
56 + Swipe to compare · {index + 1} / {responses.length}
57 + </p>
58 + </>
59 + ) : (
60 + <div className={cn("grid grid-cols-1 gap-3", GRID[responses.length] ?? "md:grid-cols-2")}>
61 + {responses.map((r, i) => (
62 + <ResponseCard key={r.id} r={r} metrics={metrics[i]} isWinner={r.id === winnerKey} />
63 + ))}
64 + </div>
65 + )}
66 +
67 + {s.winner ? <WinnerCard winner={{ ...s.winner, votes: s.winner.criteriaWon.length }} name={responses.find((r) => r.id === s.winner!.responseId)?.displayName ?? s.winner.modelKey} provider={responses.find((r) => r.id === s.winner!.responseId)?.provider ?? null} /> : null}
68 + <ComparisonTable rows={rows} />
69 + </div>
70 + );
71 +}
72 +
73 +function ResponseCard({ r, metrics, isWinner }: { r: ArenaShareSnapshot["responses"][number]; metrics: LiveMetrics; isWinner: boolean }) {
74 + return (
75 + <article className={cn("flex min-w-0 flex-col overflow-hidden rounded-xl border bg-bg-elevated", isWinner ? "border-accent/60 shadow-glow" : "border-border")} aria-label={`${r.displayName} response`}>
76 + <header className="flex items-center gap-2 border-b border-border px-3 py-2">
77 + <span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-bg-subtle">
78 + <ProviderIcon provider={r.provider} size={15} />
79 + </span>
80 + <div className="min-w-0 flex-1">
81 + <p className="truncate text-[13px] font-semibold leading-5">{r.displayName}</p>
82 + <p className="truncate text-[11.5px] text-fg-muted">{providerName(r.provider)}</p>
83 + </div>
84 + {isWinner ? (
85 + <Badge variant="accent" className="gap-1">
86 + <Trophy /> Winner
87 + </Badge>
88 + ) : null}
89 + {r.criteriaWon.length && !isWinner ? <Badge variant="outline">{r.criteriaWon.length} vote{r.criteriaWon.length === 1 ? "" : "s"}</Badge> : null}
90 + </header>
91 + <div className="min-w-0 flex-1 space-y-2.5 px-3.5 py-3 text-[14.5px] leading-6 md:max-h-[min(62vh,720px)] md:overflow-y-auto md:scrollbar-thin">
92 + {r.reasoning ? (
93 + <details className="group rounded-lg border border-border bg-bg-subtle/60">
94 + <summary className="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-[12.5px] text-fg-muted marker:content-none [&::-webkit-details-marker]:hidden">
95 + <Brain className="size-3.5" aria-hidden /> Reasoning
96 + <span className="ml-auto text-[11px] text-fg-subtle group-open:hidden">Show</span>
97 + <span className="ml-auto hidden text-[11px] text-fg-subtle group-open:inline">Hide</span>
98 + </summary>
99 + <div className="border-t border-border px-3 py-2 text-[13px] leading-6 text-fg-muted">
100 + <SimpleMarkdown className="text-[13px]">{r.reasoning}</SimpleMarkdown>
101 + </div>
102 + </details>
103 + ) : null}
104 + {r.error ? (
105 + <div className="flex items-start gap-2 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">
106 + <X className="mt-0.5 size-4 shrink-0" />
107 + <div className="min-w-0">
108 + <div className="font-medium break-words">{r.error.message}</div>
109 + <div className="font-mono text-[11px] opacity-80">{r.error.code}</div>
110 + </div>
111 + </div>
112 + ) : null}
113 + {r.content ? <SimpleMarkdown>{r.content}</SimpleMarkdown> : !r.error ? <p className="text-[13px] italic text-fg-subtle">{r.status === "stopped" ? "Stopped before any output." : "Empty response."}</p> : null}
114 + </div>
115 + <footer className="border-t border-border bg-bg-subtle/50 px-3 py-2">
116 + <MetricsStrip metrics={metrics} />
117 + </footer>
118 + </article>
119 + );
120 +}
121 +
122 +export function BlindNotice() {
123 + return (
124 + <p className="inline-flex items-center gap-1.5 rounded-md bg-bg-muted px-2 py-1 text-[11.5px] text-fg-muted">
125 + <EyeOff className="size-3.5" /> Blind Arena — identities were hidden until the vote
126 + </p>
127 + );
128 +}
modified src/components/arena/types.ts +35 −4
@@ -1,9 +1,11 @@
1 −import type { ArenaResponse, ArenaSession, PolyModel, ProviderId } from "@/lib/client/types";
1 +import type { ArenaResponse, ArenaSession, ArenaVote, PolyModel, ProviderId } from "@/lib/client/types";
2 2 import { isFastModel } from "@/components/chat/model-badges";
3 +import { computeWinner, normalizeOrder, type WinnerResult } from "@/lib/arena/scoring";
3 4
4 5 /** Wire shapes: the API serialises `createdAt` as an ISO string. */
5 6 export type ArenaResponseDto = Omit<ArenaResponse, "createdAt"> & { createdAt: string };
6 −export type ArenaSessionDto = Omit<ArenaSession, "createdAt"> & { createdAt: string; responses: ArenaResponseDto[] };
7 +export type ArenaVoteDto = Omit<ArenaVote, "createdAt"> & { createdAt: string };
8 +export type ArenaSessionDto = Omit<ArenaSession, "createdAt"> & { createdAt: string; responses: ArenaResponseDto[]; votes: ArenaVoteDto[] };
7 9
8 10 export type ColumnStatus = "idle" | "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped";
9 11
@@ -23,7 +25,7 @@ export interface ColumnState {
23 25
24 26 export const MAX_MODELS = 4;
25 27
26 −/** Rating keys — every key starts with `best` so the server keeps them exclusive per session. */
28 +/** Legacy rating keys (still written for backward compatibility — see `ratingKeyFor`). */
27 29 export const RATINGS: { key: string; label: string; short: string }[] = [
28 30 { key: "best", label: "Best overall", short: "Best" },
29 31 { key: "bestSpeed", label: "Fastest (subjective)", short: "Fastest" },
@@ -36,7 +38,7 @@ export function emptyColumn(modelKey: string): ColumnState {
36 38 return { modelKey, responseId: null, status: "idle", text: "", reasoning: "", citations: [], serverTools: [], response: null, error: null, startedAt: Date.now() };
37 39 }
38 40
39 −/** Build a column from a persisted response (history “Load”). */
41 +/** Build a column from a persisted response (history “Open”). */
40 42 export function columnFromResponse(r: ArenaResponseDto): ColumnState {
41 43 const status: ColumnStatus = r.status === "complete" ? "done" : r.status === "error" ? "error" : "stopped";
42 44 return { modelKey: r.modelKey, responseId: r.id, status, text: r.content ?? "", reasoning: r.reasoning ?? "", citations: [], serverTools: [], response: r, error: r.error ?? null, startedAt: new Date(r.createdAt).getTime() };
@@ -87,6 +89,35 @@ export function computeWinners(columns: ColumnState[]): { fastest: string | null
87 89 return { fastest: pick((c) => c.response?.ttftMs), cheapest: pick((c) => c.response?.costUsd) };
88 90 }
89 91
92 +// ---------------------------------------------------------------------------
93 +// Session helpers (history, exports, deep links)
94 +// ---------------------------------------------------------------------------
95 +export function sessionBlind(s: Pick<ArenaSessionDto, "settings">): boolean {
96 + return (s.settings as { blind?: unknown } | null)?.blind === true;
97 +}
98 +
99 +/** Display permutation for Blind Arena (identity when absent/invalid). */
100 +export function sessionOrder(s: Pick<ArenaSessionDto, "settings" | "modelKeys">): number[] {
101 + return normalizeOrder((s.settings as { blindOrder?: unknown } | null)?.blindOrder, s.modelKeys.length);
102 +}
103 +
104 +export function sessionAttachmentCount(s: Pick<ArenaSessionDto, "settings">): number {
105 + const ids = (s.settings as { attachmentIds?: unknown } | null)?.attachmentIds;
106 + return Array.isArray(ids) ? ids.length : 0;
107 +}
108 +
109 +export function sessionWinner(s: Pick<ArenaSessionDto, "responses" | "votes">): WinnerResult | null {
110 + const votes = s.votes ?? [];
111 + if (votes.length) return computeWinner(s.responses, votes);
112 + // Legacy sessions: fall back to the exclusive "best" rating.
113 + const best = s.responses.find((r) => r.ratings?.best);
114 + return best ? { modelKey: best.modelKey, responseId: best.id, criteriaWon: ["best"], votes: 1, tieBreak: null, deltas: { costUsd: null, ttftMs: null, latencyMs: null, outputTokens: null, others: 0 } } : null;
115 +}
116 +
117 +export function sessionCost(s: Pick<ArenaSessionDto, "responses">): number {
118 + return s.responses.reduce((acc, r) => acc + (r.costUsd ?? 0), 0);
119 +}
120 +
90 121 // ---------------------------------------------------------------------------
91 122 // Presets
92 123 // ---------------------------------------------------------------------------
added src/components/arena/vote-panel.tsx +99 −0
@@ -0,0 +1,99 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Plus, Trophy, X } from "lucide-react";
4 +import { PromptDialog } from "@/components/common/prompt-dialog";
5 +import { Tooltip } from "@/components/ui/tooltip";
6 +import { useLocalStorage } from "@/lib/client/hooks";
7 +import { BUILTIN_CRITERIA, customCriterion, type Criterion } from "@/lib/arena/scoring";
8 +import { cn } from "@/lib/utils";
9 +
10 +export const CRITERIA_STORAGE_KEY = "polyllm:arena-criteria";
11 +
12 +/** Custom criteria persisted in localStorage (`polyllm:arena-criteria`). */
13 +export function useCustomCriteria() {
14 + const [stored, setStored] = useLocalStorage<{ id: string; label: string }[]>(CRITERIA_STORAGE_KEY, []);
15 + const custom = React.useMemo<Criterion[]>(() => stored.map((c) => customCriterion(c.label) ?? { id: c.id, label: c.label, short: c.label, ratingKey: "bestCustom", custom: true }).filter((c) => c.id), [stored]);
16 + const add = React.useCallback(
17 + (label: string): Criterion | null => {
18 + const c = customCriterion(label);
19 + if (!c) return null;
20 + setStored((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, { id: c.id, label: c.label }].slice(0, 12)));
21 + return c;
22 + },
23 + [setStored],
24 + );
25 + const remove = React.useCallback((id: string) => setStored((prev) => prev.filter((p) => p.id !== id)), [setStored]);
26 + const all = React.useMemo(() => [...BUILTIN_CRITERIA, ...custom], [custom]);
27 + return { custom, all, add, remove };
28 +}
29 +
30 +interface Props {
31 + criteria: Criterion[];
32 + /** Criterion ids won by this response. */
33 + won: ReadonlySet<string>;
34 + onToggle: (criterionId: string, on: boolean) => void;
35 + onAddCriterion?: (label: string) => void;
36 + onRemoveCriterion?: (id: string) => void;
37 + busy?: boolean;
38 + disabled?: boolean;
39 + className?: string;
40 +}
41 +
42 +/** Criteria chips under a response. One vote per criterion per session — voting here moves the vote. */
43 +export function VotePanel({ criteria, won, onToggle, onAddCriterion, onRemoveCriterion, busy, disabled, className }: Props) {
44 + const [adding, setAdding] = React.useState(false);
45 + return (
46 + <div className={cn("flex flex-wrap items-center gap-1", className)} role="group" aria-label="Vote for this response">
47 + {criteria.map((c) => {
48 + const on = won.has(c.id);
49 + return (
50 + <span key={c.id} className="group/chip relative inline-flex">
51 + <Tooltip content={on ? `Retract “${c.label}”` : c.label}>
52 + <button
53 + type="button"
54 + disabled={busy || disabled}
55 + aria-pressed={on}
56 + onClick={() => onToggle(c.id, !on)}
57 + className={cn(
58 + "tap inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11.5px] font-medium transition-colors disabled:opacity-50",
59 + on ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-border-strong hover:text-fg",
60 + c.custom && onRemoveCriterion && "pr-5",
61 + )}
62 + >
63 + {c.id === "best" ? <Trophy className="size-3" /> : null}
64 + {c.short}
65 + </button>
66 + </Tooltip>
67 + {c.custom && onRemoveCriterion ? (
68 + <button type="button" onClick={() => onRemoveCriterion(c.id)} className="hover-reveal absolute right-0.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-subtle hover:text-danger" aria-label={`Remove criterion ${c.label}`}>
69 + <X className="size-3" />
70 + </button>
71 + ) : null}
72 + </span>
73 + );
74 + })}
75 + {onAddCriterion ? (
76 + <>
77 + <Tooltip content="Add a custom criterion">
78 + <button type="button" disabled={disabled} onClick={() => setAdding(true)} className="tap inline-flex h-7 items-center gap-1 rounded-md border border-dashed border-border px-2 text-[11.5px] font-medium text-fg-subtle transition-colors hover:border-border-strong hover:text-fg" aria-label="Add criterion">
79 + <Plus className="size-3" /> Criterion
80 + </button>
81 + </Tooltip>
82 + <PromptDialog
83 + open={adding}
84 + onOpenChange={setAdding}
85 + title="New criterion"
86 + description="Name what you are judging — e.g. “Best tone”, “Most concise”, “Follows format”. Saved on this device."
87 + label="Criterion"
88 + placeholder="Most concise"
89 + confirmLabel="Add"
90 + maxLength={40}
91 + onSubmit={(v) => {
92 + onAddCriterion(v);
93 + }}
94 + />
95 + </>
96 + ) : null}
97 + </div>
98 + );
99 +}
added src/components/arena/winner-card.tsx +78 −0
@@ -0,0 +1,78 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Coins, Timer, Trophy, Type } from "lucide-react";
4 +import { ProviderIcon } from "@/components/brand/provider-icon";
5 +import { Badge } from "@/components/ui/badge";
6 +import { criterionById, type Criterion, type WinnerResult } from "@/lib/arena/scoring";
7 +import { formatDelta } from "@/lib/arena/metrics";
8 +import { cn } from "@/lib/utils";
9 +import { BlindAvatar } from "./blind";
10 +
11 +interface Props {
12 + winner: WinnerResult;
13 + name: string;
14 + provider: string | null;
15 + /** Blind Arena, identity still hidden → neutral avatar and letter label. */
16 + hidden?: boolean;
17 + blindIndex?: number;
18 + customCriteria?: Criterion[];
19 + showCosts?: boolean;
20 + className?: string;
21 +}
22 +
23 +/**
24 + * Arena Winner card: shown once every response is final and at least one vote exists.
25 + * Most criteria won; ties go to the fastest first token. Deltas compare the winner with the mean of the others.
26 + */
27 +export function WinnerCard({ winner, name, provider, hidden, blindIndex = 0, customCriteria = [], showCosts = true, className }: Props) {
28 + const criteria = winner.criteriaWon.map((id) => criterionById(id, customCriteria));
29 + const d = winner.deltas;
30 + const better = (v: number | null) => (v === null ? null : v < 0);
31 + return (
32 + <section className={cn("animate-pop overflow-hidden rounded-xl border border-accent/40 bg-accent-soft/40 p-3.5 sm:p-4", className)} aria-label="Arena winner">
33 + <div className="flex items-start gap-3">
34 + <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-glow">
35 + <Trophy className="size-5" />
36 + </span>
37 + <div className="min-w-0 flex-1">
38 + <p className="text-[11px] font-medium uppercase tracking-[0.12em] text-accent">Arena winner</p>
39 + <div className="mt-0.5 flex min-w-0 items-center gap-2">
40 + {hidden ? <BlindAvatar index={blindIndex} size={20} /> : <ProviderIcon provider={provider} size={16} />}
41 + <h3 className="truncate text-[16px] font-semibold tracking-tight">{name}</h3>
42 + </div>
43 + <p className="mt-1 text-[12.5px] text-fg-muted">
44 + Won {criteria.length} of your criteri{criteria.length === 1 ? "on" : "a"}
45 + {winner.tieBreak === "fastest" ? " — tie broken by time to first token" : winner.tieBreak === "order" ? " — tie broken by order" : ""}.
46 + </p>
47 + <ul className="mt-2 flex flex-wrap gap-1.5" aria-label="Criteria won">
48 + {criteria.map((c) => (
49 + <li key={c.id}>
50 + <Badge variant="accent">{c.label}</Badge>
51 + </li>
52 + ))}
53 + </ul>
54 + </div>
55 + </div>
56 + {d.others > 0 ? (
57 + <dl className="mt-3 grid grid-cols-3 gap-2 border-t border-accent/20 pt-3 text-[12px] tabular-nums">
58 + {showCosts ? <Delta icon={<Coins />} label="Δ cost" value={formatDelta(d.costUsd, "usd")} good={better(d.costUsd)} hint="vs. others" /> : null}
59 + <Delta icon={<Timer />} label="Δ TTFT" value={formatDelta(d.ttftMs, "ms")} good={better(d.ttftMs)} hint="vs. others" />
60 + <Delta icon={<Type />} label="Δ output" value={formatDelta(d.outputTokens, "tokens")} good={null} hint="vs. others" />
61 + </dl>
62 + ) : null}
63 + </section>
64 + );
65 +}
66 +
67 +function Delta({ icon, label, value, good, hint }: { icon: React.ReactNode; label: string; value: string; good: boolean | null; hint: string }) {
68 + return (
69 + <div className="min-w-0">
70 + <dt className="flex items-center gap-1 text-[10.5px] uppercase tracking-wide text-fg-subtle [&_svg]:size-3">
71 + {icon}
72 + {label}
73 + </dt>
74 + <dd className={cn("truncate text-[13px] font-semibold", good === true ? "text-success" : good === false ? "text-warning" : "text-fg")}>{value}</dd>
75 + <dd className="truncate text-[10.5px] text-fg-subtle">{hint}</dd>
76 + </div>
77 + );
78 +}
modified src/components/brand/provider-icon.tsx +9 −0
@@ -78,6 +78,15 @@ export function ProviderIcon({ provider, className, size = 16 }: { provider: Pro
78 78 <path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3" />
79 79 </svg>
80 80 );
81 + case "custom":
82 + // plug / socket — a server you bring yourself
83 + return (
84 + <svg {...common} className={cn("shrink-0", className)} style={color("custom")}>
85 + <path d="M9 3v4M15 3v4" />
86 + <path d="M6 7h12v3a6 6 0 0 1-12 0z" />
87 + <path d="M12 16v5" opacity="0.7" />
88 + </svg>
89 + );
81 90 default:
82 91 return (
83 92 <svg {...common} className={cn("shrink-0 text-fg-subtle", className)}>
modified src/components/chat/chat-view.tsx +638 −125
@@ -1,18 +1,31 @@
1 1 "use client";
2 2 import * as React from "react";
3 3 import { useRouter, useSearchParams } from "next/navigation";
4 −import { ArrowDown, Menu, Pin, Share2, Sparkles, Swords, Trash2 } from "lucide-react";
4 +import { AlertTriangle, ArrowDown, Braces, Columns3, EyeOff, Globe, Menu, MoreHorizontal, Pin, Settings2, Share2, Swords, Terminal, Trash2, Wrench } from "lucide-react";
5 5 import Link from "next/link";
6 −import { useApp, invalidateConversations } from "@/components/app/store";
6 +import { useApp, invalidateConversations, AUTO_MODEL_KEY } from "@/components/app/store";
7 7 import { providerName } from "@/lib/client/providers";
8 8 import { api, streamEvents, ClientApiError } from "@/lib/client/api";
9 −import type { ChatStreamEvent, ConversationDetail, PublicConversation, PublicMessage, PolyModel, ModelPreset, PromptPreset } from "@/lib/client/types";
10 −import { MessageItem, type LiveState } from "./message";
11 −import { Composer, type PendingAttachment } from "./composer";
9 +import type { ChatAdoptResponse, ChatMetaExtras, ChatStreamEvent, ConversationDetail, PublicConversation, PublicMessage, PolyModel, ModelPreset, PromptPreset } from "@/lib/client/types";
10 +import { analyzePrompt, routeModels, explainRoute, type RouteResult, type RouteCandidate, type RouterMode } from "@/lib/client/router";
11 +import { estimateContext, estimateCost, estimateTextTokens, estimateAttachmentTokens, COST_CONFIRM_THRESHOLD_USD, type CostEstimate } from "@/lib/client/tokens";
12 +import { useIsMobile, useLocalStorage } from "@/lib/client/hooks";
13 +import { deprecationNotice, suggestReplacement, largerContextModel } from "@/lib/chat/deprecation";
14 +import { MessageItem, type LiveState, type MessageActions } from "./message";
15 +import { Composer, type PendingAttachment, type ComposerAction, type ComposerHandle } from "./composer";
12 16 import { ModelSelector } from "./model-selector";
13 −import { ModelConfig, type ChatSettings } from "./model-config";
17 +import { ModelConfig, countActiveSettings, type ChatSettings } from "./model-config";
14 18 import { ChatEmptyState } from "./empty-state";
19 +import { ContextIndicator } from "./context-indicator";
20 +import { RouterCard } from "./router-card";
21 +import { CostConfirm } from "./cost-confirm";
22 +import { CompareInline } from "./compare-inline";
23 +import { ModelPickerLauncher, type ModelPickerLauncherHandle } from "./model-launcher";
24 +import { StructuredOutputSheet, SystemPromptSheet, ToolsSheet } from "./composer-sheets";
25 +import { ConfirmDialog } from "@/components/common/confirm-dialog";
26 +import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";
15 27 import { Button } from "@/components/ui/button";
28 +import { Badge } from "@/components/ui/badge";
16 29 import { Tooltip } from "@/components/ui/tooltip";
17 30 import { toast } from "@/components/ui/toast";
18 31 import { cn, formatUsd } from "@/lib/utils";
@@ -22,12 +35,39 @@ interface Props {
22 35 initial?: ConversationDetail;
23 36 }
24 37
38 +interface Pending {
39 + text: string;
40 + attachments: PendingAttachment[];
41 +}
42 +
43 +interface ProjectLite {
44 + id: string;
45 + name: string;
46 + instructions: string | null;
47 + preferredModelKeys: string[];
48 +}
49 +
50 +type MetaEvent = Extract<ChatStreamEvent, { type: "meta" }> & ChatMetaExtras;
51 +
52 +const LS_ROUTER_ALWAYS = "polyllm:router-always";
53 +const LS_ROUTER_MODE = "polyllm:router-mode";
54 +const SUMMARY_PROMPT = "Summarize this conversation so far for hand-off to a fresh session: goals, key facts, decisions, constraints, open questions and the exact state of any code or data. Be complete but compact, in Markdown, without preamble.";
55 +
25 56 const emptyLive = (): LiveState => ({ text: "", reasoning: "", tools: [], serverTools: [], citations: [], startedAt: Date.now() });
26 57
58 +function mkMessage(base: Partial<PublicMessage> & Pick<PublicMessage, "id" | "role" | "content">): PublicMessage {
59 + return { conversationId: "", parts: [], modelKey: null, provider: null, status: "complete", finishReason: null, error: null, usage: null, settings: null, latencyMs: null, ttftMs: null, costUsd: null, parentMessageId: null, version: 1, active: true, createdAt: new Date().toISOString(), ...base } as PublicMessage;
60 +}
61 +
62 +function attachmentsOf(m: PublicMessage) {
63 + return (m.parts as { type: string; kind?: string; sizeBytes?: number; width?: number | null; height?: number | null }[]).filter((p) => p.type === "attachment").map((p) => ({ kind: p.kind ?? "file", sizeBytes: p.sizeBytes ?? 0, width: p.width, height: p.height }));
64 +}
65 +
27 66 export function ChatView({ conversationId, initial }: Props) {
28 67 const router = useRouter();
29 68 const search = useSearchParams();
30 − const { modelsByKey, selectedModelKey, setSelectedModelKey, preferences, setSidebarOpen, models, connectedProviders } = useApp();
69 + const isMobile = useIsMobile();
70 + const { modelsByKey, models, selectedModelKey, setSelectedModelKey, preferences, setSidebarOpen, connectedProviders, favorites, activeProjectId } = useApp();
31 71
32 72 const [conversation, setConversation] = React.useState<PublicConversation | null>(initial?.conversation ?? null);
33 73 const [messages, setMessages] = React.useState<PublicMessage[]>(() => (initial?.messages ?? []).filter((m) => (m as unknown as { active?: boolean }).active !== false));
@@ -38,11 +78,44 @@ export function ChatView({ conversationId, initial }: Props) {
38 78 const [live, setLive] = React.useState<LiveState | null>(null);
39 79 const [busy, setBusy] = React.useState(false);
40 80 const [draft, setDraft] = React.useState("");
81 + const [ephemeral, setEphemeral] = React.useState(() => !conversationId && search.get("temporary") === "1");
82 + const [turnMeta, setTurnMeta] = React.useState<Record<string, { requestId?: string; errorAt?: number }>>({});
83 + const [project, setProject] = React.useState<ProjectLite | null>(null);
84 + const [routerState, setRouterState] = React.useState<{ result: RouteResult; pending: Pending } | null>(null);
85 + const [costState, setCostState] = React.useState<{ pending: Pending; modelKey: string; estimate: CostEstimate; alternatives: RouteCandidate[] } | null>(null);
86 + const [compare, setCompare] = React.useState<{ prompt: string; editable: boolean; initialModels: string[] } | null>(null);
87 + const [pick, setPick] = React.useState<{ onPick: (key: string) => void } | null>(null);
88 + const [sysOpen, setSysOpen] = React.useState(false);
89 + const [structOpen, setStructOpen] = React.useState(false);
90 + const [toolsOpen, setToolsOpen] = React.useState(false);
91 + const [moreOpen, setMoreOpen] = React.useState(false);
92 + const [deleteOpen, setDeleteOpen] = React.useState(false);
93 + const [summarizing, setSummarizing] = React.useState(false);
94 + const [alwaysAuto, setAlwaysAuto] = useLocalStorage<boolean>(LS_ROUTER_ALWAYS, false);
95 + const [routerMode, setRouterMode] = useLocalStorage<RouterMode>(LS_ROUTER_MODE, "balanced");
96 + const [now] = React.useState(() => Date.now());
97 +
41 98 const abortRef = React.useRef<AbortController | null>(null);
42 99 const scrollRef = React.useRef<HTMLDivElement>(null);
100 + const composerRef = React.useRef<ComposerHandle>(null);
101 + const pickerRef = React.useRef<ModelPickerLauncherHandle>(null);
102 + const messagesRef = React.useRef<PublicMessage[]>(messages);
103 + const userPickedModel = React.useRef(false);
43 104 const [atBottom, setAtBottom] = React.useState(true);
44 105 const presetApplied = React.useRef(false);
45 106
107 + React.useEffect(() => {
108 + messagesRef.current = messages;
109 + }, [messages]);
110 +
111 + // ?temporary=1 (also when navigating from a normal new chat via the command palette)
112 + React.useEffect(() => {
113 + if (!conversationId && search.get("temporary") === "1") {
114 + // eslint-disable-next-line react-hooks/set-state-in-effect
115 + setEphemeral(true);
116 + }
117 + }, [conversationId, search]);
118 +
46 119 // Model for new chats follows the global selection; existing conversations keep theirs.
47 120 React.useEffect(() => {
48 121 if (!conversationId && !modelKey && selectedModelKey) {
@@ -51,21 +124,46 @@ export function ChatView({ conversationId, initial }: Props) {
51 124 }
52 125 }, [conversationId, modelKey, selectedModelKey]);
53 126
54 − // Apply ?preset= / ?prompt= / ?model= for new chats.
127 + // Project instructions + preferred model for new chats in the active project (degrades silently on 404).
128 + React.useEffect(() => {
129 + if (conversationId || !activeProjectId) return;
130 + let cancelled = false;
131 + api<{ project: ProjectLite }>(`/api/projects/${activeProjectId}`)
132 + .then((res) => {
133 + if (cancelled || !res?.project) return;
134 + setProject({ id: res.project.id, name: res.project.name, instructions: res.project.instructions ?? null, preferredModelKeys: res.project.preferredModelKeys ?? [] });
135 + const pref = res.project.preferredModelKeys?.[0];
136 + if (pref && modelsByKey.has(pref) && !userPickedModel.current && messagesRef.current.length === 0) setModelKey(pref);
137 + })
138 + .catch(() => {
139 + /* Projects API not available yet or project removed: no instructions */
140 + });
141 + return () => {
142 + cancelled = true;
143 + };
144 + }, [conversationId, activeProjectId, modelsByKey]);
145 + const activeProject = !conversationId && activeProjectId && project?.id === activeProjectId ? project : null;
146 +
147 + // Apply ?preset= / ?prompt= / ?promptId= / ?model= for new chats.
55 148 React.useEffect(() => {
56 149 if (conversationId || presetApplied.current) return;
57 150 const presetId = search.get("preset");
58 151 const promptId = search.get("prompt");
152 + const libraryPromptId = search.get("promptId");
59 153 const m = search.get("model");
60 − if (!presetId && !promptId && !m) return;
154 + if (!presetId && !promptId && !m && !libraryPromptId) return;
61 155 presetApplied.current = true;
62 156 (async () => {
63 − if (m && modelsByKey.has(m)) setModelKey(m);
157 + if (m && (modelsByKey.has(m) || m === AUTO_MODEL_KEY)) {
158 + userPickedModel.current = true;
159 + setModelKey(m);
160 + }
64 161 if (presetId || promptId) {
65 162 const res = await api<{ modelPresets: ModelPreset[]; promptPresets: PromptPreset[] }>("/api/presets");
66 163 if (presetId) {
67 164 const p = res.modelPresets.find((x) => x.id === presetId);
68 165 if (p) {
166 + userPickedModel.current = true;
69 167 setModelKey(p.modelKey);
70 168 const tools = (p.tools as { builtin?: string[] })?.builtin;
71 169 setSettings({ ...(p.parameters as ChatSettings), ...(tools?.length ? { tools } : {}) });
@@ -83,13 +181,52 @@ export function ChatView({ conversationId, initial }: Props) {
83 181 }
84 182 }
85 183 }
184 + if (libraryPromptId) {
185 + // Prompt library (Projects workstream). 404 → ignore.
186 + const res = await api<{ prompt?: { content?: string; body?: string; text?: string } }>(`/api/prompts/${libraryPromptId}`).catch(() => null);
187 + const content = res?.prompt?.content ?? res?.prompt?.body ?? res?.prompt?.text;
188 + if (content) setDraft(content);
189 + }
86 190 })().catch(() => {});
87 191 }, [conversationId, search, modelsByKey]);
88 192
89 − const model: PolyModel | undefined = modelKey ? modelsByKey.get(modelKey) : undefined;
193 + // Prompt library → composer insertion event (see docs/upgrade-notes/A-chat.md).
194 + React.useEffect(() => {
195 + const onInsert = (e: Event) => {
196 + const text = (e as CustomEvent<{ text?: string }>).detail?.text;
197 + if (text) composerRef.current?.insert(text);
198 + };
199 + window.addEventListener("polyllm:prompt-insert", onInsert);
200 + return () => window.removeEventListener("polyllm:prompt-insert", onInsert);
201 + }, []);
202 +
203 + const isAuto = modelKey === AUTO_MODEL_KEY;
204 + const model: PolyModel | undefined = modelKey && !isAuto ? modelsByKey.get(modelKey) : undefined;
90 205 const modelUsable = Boolean(model && connectedProviders.has(model.provider));
206 + const usableModels = React.useMemo(() => models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated"), [models, connectedProviders]);
207 + const fullSystemPrompt = React.useMemo(() => [activeProject?.instructions?.trim(), systemPrompt.trim()].filter(Boolean).join("\n\n") || null, [activeProject, systemPrompt]);
208 + const notice = React.useMemo(() => deprecationNotice(model, now), [model, now]);
209 + const replacement = React.useMemo(() => (model && notice ? suggestReplacement(model, models, connectedProviders) : null), [model, notice, models, connectedProviders]);
210 +
211 + // --- context + cost estimate ----------------------------------------------------------------------
212 + const expectedOut = settings.maxTokens && settings.maxTokens < 4000 ? settings.maxTokens : 600;
213 + const estimate = React.useMemo(
214 + () =>
215 + estimateContext({
216 + historyText: messages.filter((m) => m.status !== "streaming").map((m) => m.content),
217 + historyAttachments: messages.flatMap(attachmentsOf),
218 + draft,
219 + attachments,
220 + systemPrompt: fullSystemPrompt,
221 + model,
222 + expectedOutput: expectedOut,
223 + }),
224 + [messages, draft, attachments, fullSystemPrompt, model, expectedOut],
225 + );
226 + const cost = React.useMemo(() => estimateCost(model, estimate.total, expectedOut), [model, estimate.total, expectedOut]);
227 + const largerModel = React.useMemo(() => (estimate.level !== "ok" ? largerContextModel(model, models, connectedProviders) : null), [estimate.level, model, models, connectedProviders]);
91 228
92 − // Scrolling
229 + // --- scrolling --------------------------------------------------------------------------------------
93 230 const scrollToBottom = React.useCallback((smooth = false) => {
94 231 const el = scrollRef.current;
95 232 if (!el) return;
@@ -97,19 +234,23 @@ export function ChatView({ conversationId, initial }: Props) {
97 234 }, []);
98 235 React.useEffect(() => {
99 236 if (atBottom) scrollToBottom();
100 − }, [messages, live, atBottom, scrollToBottom]);
237 + }, [messages, live, atBottom, scrollToBottom, compare]);
101 238 React.useEffect(() => {
102 239 const hash = window.location.hash.slice(1);
103 240 if (hash) document.getElementById(hash)?.scrollIntoView({ block: "center" });
104 241 else scrollToBottom();
105 242 }, [conversationId, scrollToBottom]);
106 −
107 243 const onScroll = () => {
108 244 const el = scrollRef.current;
109 245 if (!el) return;
110 246 setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80);
111 247 };
112 248
249 + // --- model picker launcher (retry with…, switch model, choose another) ------------------------------
250 + React.useEffect(() => {
251 + if (pick) requestAnimationFrame(() => pickerRef.current?.open());
252 + }, [pick]);
253 +
113 254 const persistConversationMeta = React.useCallback(
114 255 async (patch: Record<string, unknown>) => {
115 256 if (!conversation) return;
@@ -124,23 +265,39 @@ export function ChatView({ conversationId, initial }: Props) {
124 265 [conversation],
125 266 );
126 267
127 − const changeModel = (key: string) => {
128 − setModelKey(key);
129 − setSelectedModelKey(key);
130 − if (conversation) void persistConversationMeta({ modelKey: key });
268 + const changeModel = React.useCallback(
269 + (key: string, opts: { keepGlobal?: boolean } = {}) => {
270 + userPickedModel.current = true;
271 + setModelKey(key);
272 + if (!opts.keepGlobal) setSelectedModelKey(key);
273 + if (conversation && key !== AUTO_MODEL_KEY) void persistConversationMeta({ modelKey: key });
274 + },
275 + [conversation, persistConversationMeta, setSelectedModelKey],
276 + );
277 +
278 + const updateSettings = (s: ChatSettings) => {
279 + setSettings(s);
280 + if (conversation) void persistConversationMeta({ settings: s });
281 + };
282 + const updateSystemPrompt = (v: string) => {
283 + setSystemPrompt(v);
284 + if (conversation) void persistConversationMeta({ systemPrompt: v || null });
131 285 };
132 286
287 + // --- streaming turn ---------------------------------------------------------------------------------
133 288 const run = React.useCallback(
134 − async (body: Record<string, unknown>, opts: { optimisticUser?: PublicMessage | null; replaceAssistantId?: string; truncateAfterIndex?: number } = {}) => {
135 − if (!modelKey) return toast.warning("Pick a model first");
289 + async (body: Record<string, unknown>, opts: { optimisticUser?: PublicMessage | null; replaceAssistantId?: string; truncateAfterIndex?: number; modelKey?: string; historyUntil?: number } = {}) => {
290 + const key = opts.modelKey ?? modelKey;
291 + if (!key || key === AUTO_MODEL_KEY) return toast.warning("Pick a model first");
136 292 if (busy) return;
293 + const keyModel = modelsByKey.get(key);
137 294 setBusy(true);
138 295 const controller = new AbortController();
139 296 abortRef.current = controller;
140 297 const liveState = emptyLive();
141 298 setLive(liveState);
142 299 let assistantId: string | null = null;
143 − let convId = conversation?.id ?? null;
300 + let convId = ephemeral ? null : conversation?.id ?? null;
144 301 let flushTimer: ReturnType<typeof setTimeout> | null = null;
145 302 const flush = () => {
146 303 flushTimer = null;
@@ -150,37 +307,54 @@ export function ChatView({ conversationId, initial }: Props) {
150 307 if (!flushTimer) flushTimer = setTimeout(flush, 40);
151 308 };
152 309
310 + // Temporary chats have no server history: replay prior turns.
311 + const snapshot = messagesRef.current;
312 + const historyEnd = opts.historyUntil ?? opts.truncateAfterIndex ?? snapshot.length;
313 + const history = ephemeral ? snapshot.slice(0, historyEnd).filter((m) => (m.role === "user" || m.role === "assistant") && m.content && m.status !== "streaming" && !m.id.startsWith("tmp_")).map((m) => ({ role: m.role as "user" | "assistant", content: m.content })) : undefined;
314 +
153 315 // optimistic UI
154 316 setMessages((prev) => {
155 317 let next = opts.truncateAfterIndex !== undefined ? prev.slice(0, opts.truncateAfterIndex) : [...prev];
156 318 if (opts.replaceAssistantId) next = next.filter((m) => m.id !== opts.replaceAssistantId);
157 319 if (opts.optimisticUser) next.push(opts.optimisticUser);
158 − next.push({ id: "pending-assistant", conversationId: convId ?? "", role: "assistant", content: "", parts: [], modelKey, provider: model?.provider ?? null, status: "streaming", finishReason: null, error: null, usage: null, latencyMs: null, ttftMs: null, costUsd: null, parentMessageId: null, version: 1, active: true, createdAt: new Date().toISOString() });
320 + next.push(mkMessage({ id: "pending-assistant", conversationId: convId ?? "", role: "assistant", content: "", modelKey: key, provider: keyModel?.provider ?? null, status: "streaming" }));
159 321 return next;
160 322 });
161 323
162 324 try {
163 325 await streamEvents(
164 326 "/api/chat",
165 − { ...body, modelKey, conversationId: convId ?? undefined, systemPrompt: systemPrompt || null, settings: Object.keys(settings).length ? settings : undefined },
327 + {
328 + ...body,
329 + modelKey: key,
330 + conversationId: convId ?? undefined,
331 + systemPrompt: fullSystemPrompt,
332 + settings: Object.keys(settings).length ? settings : undefined,
333 + projectId: !convId && !ephemeral && activeProjectId ? activeProjectId : undefined,
334 + ephemeral: ephemeral || undefined,
335 + history,
336 + },
166 337 (raw) => {
167 − const ev = raw as ChatStreamEvent;
338 + const ev = raw as ChatStreamEvent & ChatMetaExtras;
168 339 switch (ev.type) {
169 − case "meta":
170 − convId = ev.conversationId;
171 − assistantId = ev.assistantMessageId;
340 + case "meta": {
341 + const meta = ev as MetaEvent;
342 + assistantId = meta.assistantMessageId;
343 + if (!meta.ephemeral) convId = meta.conversationId;
172 344 setMessages((prev) =>
173 345 prev.map((m) => {
174 − if (m.id === "pending-assistant") return { ...m, id: ev.assistantMessageId, conversationId: ev.conversationId };
175 − if (opts.optimisticUser && m.id === opts.optimisticUser.id && ev.userMessage) return ev.userMessage;
346 + if (m.id === "pending-assistant") return { ...m, id: meta.assistantMessageId, conversationId: meta.conversationId };
347 + if (opts.optimisticUser && m.id === opts.optimisticUser.id && meta.userMessage) return meta.userMessage;
176 348 return m;
177 349 }),
178 350 );
179 − if (ev.isNewConversation) {
180 − window.history.replaceState(null, "", `/app/chat/${ev.conversationId}`);
181 − setConversation({ id: ev.conversationId, title: "New chat", folderId: null, pinned: false, archived: false, modelKey, provider: model?.provider ?? null, systemPrompt: systemPrompt || null, settings, messageCount: 0, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, parentConversationId: null, lastMessageAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
351 + if (meta.requestId) setTurnMeta((t) => ({ ...t, [meta.assistantMessageId]: { ...t[meta.assistantMessageId], requestId: meta.requestId } }));
352 + if (meta.isNewConversation && !meta.ephemeral) {
353 + window.history.replaceState(null, "", `/app/chat/${meta.conversationId}`);
354 + setConversation({ id: meta.conversationId, title: "New chat", folderId: null, pinned: false, archived: false, modelKey: key, provider: keyModel?.provider ?? null, systemPrompt: fullSystemPrompt, settings, messageCount: 0, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, parentConversationId: null, lastMessageAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PublicConversation);
182 355 }
183 356 break;
357 + }
184 358 case "text-delta":
185 359 if (!liveState.firstTokenAt) liveState.firstTokenAt = Date.now();
186 360 liveState.text += ev.text;
@@ -215,7 +389,6 @@ export function ChatView({ conversationId, initial }: Props) {
215 389 t.isError = ev.isError;
216 390 t.durationMs = ev.durationMs;
217 391 }
218 − // Tool loop: the next round appends more text — separate visually.
219 392 if (liveState.text && !liveState.text.endsWith("\n\n")) liveState.text += "\n\n";
220 393 schedule();
221 394 break;
@@ -230,19 +403,20 @@ export function ChatView({ conversationId, initial }: Props) {
230 403 break;
231 404 case "refusal":
232 405 break;
233 − case "error":
234 − if (!liveState.text) toast.error("Generation failed", ev.error.message);
406 + case "error": {
407 + const id = assistantId ?? "pending-assistant";
408 + setTurnMeta((t) => ({ ...t, [id]: { ...t[id], errorAt: Date.now() } }));
235 409 break;
410 + }
236 411 case "done": {
237 412 if (flushTimer) clearTimeout(flushTimer);
238 413 setMessages((prev) => prev.map((m) => (m.id === (assistantId ?? "pending-assistant") ? ev.message : m)));
239 − if (ev.title) {
240 − setConversation((c) => (c ? { ...c, title: ev.title! } : c));
241 − invalidateConversations();
242 − } else {
414 + if (ev.error) setTurnMeta((t) => ({ ...t, [ev.message.id]: { ...t[ev.message.id], errorAt: t[ev.message.id]?.errorAt ?? Date.now() } }));
415 + if (!ephemeral) {
416 + if (ev.title) setConversation((c) => (c ? { ...c, title: ev.title! } : c));
243 417 invalidateConversations();
418 + setConversation((c) => (c ? { ...c, totalCostUsd: (c.totalCostUsd ?? 0) + (ev.costUsd ?? 0), messageCount: c.messageCount + 1 } : c));
244 419 }
245 − setConversation((c) => (c ? { ...c, totalCostUsd: (c.totalCostUsd ?? 0) + (ev.costUsd ?? 0), messageCount: c.messageCount + 1 } : c));
246 420 break;
247 421 }
248 422 }
@@ -251,10 +425,12 @@ export function ChatView({ conversationId, initial }: Props) {
251 425 );
252 426 } catch (e) {
253 427 if ((e as Error).name === "AbortError") {
254 − // Stopped by the user: server persists the partial message; reload it.
428 + // Stopped by the user: the server persists the partial message; reload it (not for temporary chats).
255 429 if (convId) {
256 430 const res = await api<ConversationDetail>(`/api/conversations/${convId}`).catch(() => null);
257 431 if (res) setMessages(res.messages.filter((m) => (m as unknown as { active?: boolean }).active !== false));
432 + } else {
433 + setMessages((prev) => prev.map((m) => (m.id === (assistantId ?? "pending-assistant") ? { ...m, content: liveState.text, status: "stopped" as const, finishReason: "cancelled" } : m)));
258 434 }
259 435 } else {
260 436 const err = e as ClientApiError;
@@ -269,49 +445,107 @@ export function ChatView({ conversationId, initial }: Props) {
269 445 abortRef.current = null;
270 446 }
271 447 },
272 − [busy, conversation, model, modelKey, settings, systemPrompt, router],
448 + [busy, conversation, modelKey, modelsByKey, settings, fullSystemPrompt, router, ephemeral, activeProjectId],
273 449 );
274 450
275 − const send = (text: string) => {
276 − if (!modelUsable) return toast.warning("This model's provider isn't connected", "Add a key in Settings → Providers.");
277 − const optimistic: PublicMessage = {
451 + // --- send pipeline: Smart Router → cost confirm → run ----------------------------------------------
452 + const restorePending = (p: Pending) => {
453 + setDraft(p.text);
454 + setAttachments(p.attachments);
455 + };
456 +
457 + const doSend = (key: string, pending: Pending) => {
458 + if (key !== modelKey) changeModel(key, { keepGlobal: isAuto });
459 + const km = modelsByKey.get(key);
460 + const optimistic = mkMessage({
278 461 id: `tmp_${Date.now()}`,
279 462 conversationId: conversation?.id ?? "",
280 463 role: "user",
281 − content: text,
282 − parts: [{ type: "text", text }, ...attachments.map((a) => ({ type: "attachment" as const, attachmentId: a.id, kind: a.kind, name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes, width: a.width, height: a.height }))],
283 − modelKey: null,
284 − provider: null,
285 − status: "complete",
286 − finishReason: null,
287 − error: null,
288 − usage: null,
289 − latencyMs: null,
290 − ttftMs: null,
291 − costUsd: null,
292 − parentMessageId: null,
293 − version: 1,
294 − active: true,
295 − createdAt: new Date().toISOString(),
296 − };
297 − const attIds = attachments.map((a) => a.id);
464 + content: pending.text,
465 + parts: [...(pending.text ? [{ type: "text" as const, text: pending.text }] : []), ...pending.attachments.map((a) => ({ type: "attachment" as const, attachmentId: a.id, kind: a.kind, name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes, width: a.width, height: a.height }))],
466 + });
298 467 setAttachments([]);
299 − void run({ action: "send", message: { text, attachmentIds: attIds } }, { optimisticUser: optimistic });
468 + void km;
469 + void run({ action: "send", message: { text: pending.text, attachmentIds: pending.attachments.map((a) => a.id) } }, { optimisticUser: optimistic, modelKey: key });
470 + };
471 +
472 + const routeFor = (text: string, atts: PendingAttachment[], mode: RouterMode = routerMode): RouteResult => {
473 + const analysis = analyzePrompt(text, atts, { historyTokens: estimate.history, systemPrompt: fullSystemPrompt, webSearch: settings.webSearch, responseFormat: Boolean(settings.responseFormat) });
474 + return routeModels(usableModels, analysis, mode, { favorites, budgetCapUsd: COST_CONFIRM_THRESHOLD_USD });
475 + };
476 +
477 + const proceed = (key: string, pending: Pending) => {
478 + const km = modelsByKey.get(key);
479 + const inputTokens = estimate.history + estimateTextTokens(pending.text) + pending.attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0) + estimateTextTokens(fullSystemPrompt ?? "");
480 + const est = estimateCost(km, inputTokens, expectedOut);
481 + if (est.usd !== null && est.usd > COST_CONFIRM_THRESHOLD_USD) {
482 + const cheap = routeFor(pending.text, pending.attachments, "cheapest");
483 + const alternatives = [cheap.recommended, ...cheap.alternatives].filter((c): c is RouteCandidate => Boolean(c) && c!.model.key !== key && c!.estimatedUsd !== null && (c!.estimatedUsd as number) < (est.usd as number)).slice(0, 3);
484 + setCostState({ pending, modelKey: key, estimate: est, alternatives });
485 + return;
486 + }
487 + doSend(key, pending);
488 + };
489 +
490 + const send = (text: string) => {
491 + if (!text && attachments.length === 0) return;
492 + const pending: Pending = { text, attachments: [...attachments] };
493 + if (isAuto) {
494 + if (!usableModels.length) return toast.warning("No provider connected", "Add a key in Settings → Providers.");
495 + const result = routeFor(text, pending.attachments);
496 + if (!result.recommended) {
497 + restorePending(pending);
498 + return toast.warning("No compatible model", "None of your connected models can take this prompt (vision, files or context).");
499 + }
500 + if (alwaysAuto && !result.needsConfirmation) {
501 + toast.info(`Auto-routed to ${result.recommended.model.displayName}`, explainRoute(result.recommended));
502 + proceed(result.recommended.model.key, pending);
503 + } else {
504 + setRouterState({ result, pending });
505 + }
506 + return;
507 + }
508 + if (!modelKey) return toast.warning("Pick a model first");
509 + if (!modelUsable) {
510 + restorePending(pending);
511 + return toast.warning("This model's provider isn't connected", "Add a key in Settings → Providers.");
512 + }
513 + proceed(modelKey, pending);
300 514 };
301 515
302 516 const stop = () => abortRef.current?.abort();
303 517
304 − const regenerate = (m: PublicMessage) => {
518 + // --- message actions ---------------------------------------------------------------------------------
519 + const lastUserBefore = (idx: number) => {
520 + for (let i = idx - 1; i >= 0; i--) if (messages[i].role === "user") return i;
521 + return -1;
522 + };
523 +
524 + const regenerate = (m: PublicMessage, key?: string) => {
305 525 const idx = messages.findIndex((x) => x.id === m.id);
306 − void run({ action: "regenerate", targetMessageId: m.id }, { truncateAfterIndex: idx });
526 + if (key && key !== modelKey) changeModel(key, { keepGlobal: isAuto });
527 + if (ephemeral) {
528 + const ui = lastUserBefore(idx);
529 + if (ui < 0) return;
530 + const u = messages[ui];
531 + void run({ action: "send", message: { text: u.content, attachmentIds: [] } }, { truncateAfterIndex: idx, historyUntil: ui, modelKey: key });
532 + return;
533 + }
534 + void run({ action: "regenerate", targetMessageId: m.id }, { truncateAfterIndex: idx, modelKey: key });
307 535 };
536 + const regenerateWith = (m: PublicMessage) => setPick({ onPick: (key) => regenerate(m, key) });
308 537 const retry = (m: PublicMessage) => {
309 538 const idx = messages.findIndex((x) => x.id === m.id);
539 + if (ephemeral) return regenerate(m);
310 540 void run({ action: "retry", targetMessageId: m.id }, { truncateAfterIndex: idx });
311 541 };
312 542 const edit = (m: PublicMessage, text: string) => {
313 543 const idx = messages.findIndex((x) => x.id === m.id);
314 − const optimistic: PublicMessage = { ...m, id: `tmp_${Date.now()}`, content: text, parts: [{ type: "text", text }, ...m.parts.filter((p) => p.type === "attachment")] };
544 + const optimistic = mkMessage({ ...m, id: `tmp_${Date.now()}`, content: text, parts: [{ type: "text", text }, ...m.parts.filter((p) => p.type === "attachment")] });
545 + if (ephemeral) {
546 + void run({ action: "send", message: { text, attachmentIds: [] } }, { optimisticUser: optimistic, truncateAfterIndex: idx, historyUntil: idx });
547 + return;
548 + }
315 549 void run({ action: "edit", targetMessageId: m.id, message: { text } }, { optimisticUser: optimistic, truncateAfterIndex: idx });
316 550 };
317 551 const cont = (m: PublicMessage) => {
@@ -326,10 +560,66 @@ export function ChatView({ conversationId, initial }: Props) {
326 560 router.push(`/app/chat/${res.conversation.id}`);
327 561 };
328 562 const del = async (m: PublicMessage) => {
563 + if (ephemeral) {
564 + setMessages((prev) => prev.filter((x) => x.id !== m.id));
565 + return;
566 + }
329 567 if (!conversation) return;
330 568 await api(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "delete-message", messageId: m.id } });
331 569 setMessages((prev) => prev.filter((x) => x.id !== m.id));
332 570 };
571 + const quote = (m: PublicMessage) => {
572 + const q = m.content
573 + .trim()
574 + .split("\n")
575 + .map((l) => `> ${l}`)
576 + .join("\n");
577 + composerRef.current?.insert(`${q}\n\n`);
578 + };
579 + const saveAsPrompt = async (m: PublicMessage) => {
580 + const name = m.content.replace(/\s+/g, " ").trim().slice(0, 60) || "Saved prompt";
581 + try {
582 + await api("/api/prompts", { method: "POST", json: { name, content: m.content, source: "chat" } });
583 + toast.success("Saved to your prompt library");
584 + } catch (e) {
585 + const err = e as ClientApiError;
586 + if (err.status === 404 || err.status === 405) toast.info("Prompt library not available yet");
587 + else toast.error("Could not save the prompt", err.message);
588 + }
589 + };
590 + const exportMessage = async (m: PublicMessage) => {
591 + const who = m.role === "user" ? "User" : modelsByKey.get(m.modelKey ?? "")?.displayName ?? m.modelKey ?? "Assistant";
592 + const md = `### ${who} · ${new Date(m.createdAt).toLocaleString()}\n\n${m.content}\n`;
593 + await navigator.clipboard.writeText(md).catch(() => {});
594 + toast.success("Message copied as Markdown");
595 + };
596 + const compareFrom = (m: PublicMessage) => {
597 + const idx = messages.findIndex((x) => x.id === m.id);
598 + const ui = lastUserBefore(idx);
599 + const prompt = ui >= 0 ? messages[ui].content : "";
600 + if (!prompt.trim()) return toast.warning("Nothing to compare", "This response has no user prompt before it.");
601 + const base = m.modelKey && modelsByKey.has(m.modelKey) ? [m.modelKey] : model ? [model.key] : [];
602 + setCompare({ prompt, editable: false, initialModels: base });
603 + setAtBottom(true);
604 + };
605 + const openCompareFromComposer = () => {
606 + setCompare({ prompt: draft, editable: true, initialModels: model ? [model.key] : [] });
607 + setAtBottom(true);
608 + };
609 + const onAdopted = (res: ChatAdoptResponse, key: string) => {
610 + setCompare(null);
611 + if (res.isNewConversation) {
612 + invalidateConversations();
613 + setSelectedModelKey(key);
614 + router.push(`/app/chat/${res.conversation.id}`);
615 + return;
616 + }
617 + setMessages((prev) => [...prev, res.message]);
618 + setConversation(res.conversation);
619 + changeModel(key, { keepGlobal: isAuto });
620 + invalidateConversations();
621 + toast.success(`Continuing with ${modelsByKey.get(key)?.displayName ?? key}`);
622 + };
333 623
334 624 const share = async () => {
335 625 if (!conversation) return;
@@ -338,100 +628,323 @@ export function ChatView({ conversationId, initial }: Props) {
338 628 await navigator.clipboard.writeText(url).catch(() => {});
339 629 toast.success("Share link copied", url);
340 630 };
631 + const deleteConversation = async () => {
632 + if (!conversation) return;
633 + await api(`/api/conversations/${conversation.id}`, { method: "DELETE" });
634 + invalidateConversations();
635 + router.push("/app/chat");
636 + };
637 +
638 + const toggleTemporary = () => {
639 + if (conversation || messages.length) {
640 + router.push("/app/chat?temporary=1");
641 + return;
642 + }
643 + const next = !ephemeral;
644 + setEphemeral(next);
645 + window.history.replaceState(null, "", next ? "/app/chat?temporary=1" : "/app/chat");
646 + };
647 +
648 + // Summarize the context with the current model, then start a new chat seeded with the summary.
649 + const summarize = async () => {
650 + if (!model || !modelKey || summarizing || busy) return;
651 + setSummarizing(true);
652 + try {
653 + const history = messages.filter((m) => (m.role === "user" || m.role === "assistant") && m.content && m.status !== "streaming").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
654 + let summary = "";
655 + let failure: string | null = null;
656 + await streamEvents("/api/chat", { modelKey, ephemeral: true, history, message: { text: SUMMARY_PROMPT }, systemPrompt: null }, (raw) => {
657 + const ev = raw as ChatStreamEvent;
658 + if (ev.type === "text-delta") summary += ev.text;
659 + if (ev.type === "error") failure = ev.error.message;
660 + });
661 + if (!summary.trim()) throw new Error(failure ?? "The model returned an empty summary");
662 + const sys = [systemPrompt.trim(), `Context carried over from a previous conversation (summarized):\n\n${summary.trim()}`].filter(Boolean).join("\n\n");
663 + const res = await api<{ conversation: PublicConversation }>("/api/conversations", { method: "POST", json: { title: `${conversation?.title ?? "Chat"} (continued)`, modelKey, systemPrompt: sys, settings } });
664 + invalidateConversations();
665 + toast.success("Context summarized", "A new chat was started with the summary as system prompt.");
666 + router.push(`/app/chat/${res.conversation.id}`);
667 + } catch (e) {
668 + toast.error("Could not summarize the context", (e as Error).message);
669 + } finally {
670 + setSummarizing(false);
671 + }
672 + };
341 673
342 − const actions = React.useMemo(() => ({ onEdit: edit, onRegenerate: regenerate, onRetry: retry, onContinue: cont, onBranch: conversation ? branch : undefined, onDelete: conversation ? del : undefined }), [conversation, messages]); // eslint-disable-line react-hooks/exhaustive-deps
674 + const actions = React.useMemo<MessageActions>(
675 + () => ({
676 + onEdit: edit,
677 + onRegenerate: regenerate,
678 + onRegenerateWith: regenerateWith,
679 + onRetry: retry,
680 + onContinue: ephemeral ? undefined : cont,
681 + onBranch: conversation && !ephemeral ? branch : undefined,
682 + onDelete: conversation || ephemeral ? del : undefined,
683 + onCompare: usableModels.length > 1 ? compareFrom : undefined,
684 + onQuote: quote,
685 + onSaveAsPrompt: saveAsPrompt,
686 + onExport: exportMessage,
687 + onSwitchModel: (key) => changeModel(key),
688 + }),
689 + // eslint-disable-next-line react-hooks/exhaustive-deps
690 + [conversation, messages, ephemeral, modelKey, usableModels.length],
691 + );
343 692
344 693 const lastAssistantIdx = messages.map((m) => m.role).lastIndexOf("assistant");
694 + const composerDisabled = isAuto ? usableModels.length === 0 : !model || (!modelUsable && models.length > 0);
695 + const activeSettings = countActiveSettings(settings);
696 +
697 + // --- `+` menu extras ---------------------------------------------------------------------------------
698 + const extraActions: (ComposerAction | "separator")[] = [
699 + ...(model?.capabilities.tools ? [{ key: "tools", label: "Tools", icon: <Wrench />, hint: settings.tools?.length ? `${settings.tools.length} on` : undefined, selected: Boolean(settings.tools?.length), onSelect: () => setToolsOpen(true) }] : []),
700 + ...(model?.capabilities.webSearch ? [{ key: "web", label: "Web search", icon: <Globe />, selected: Boolean(settings.webSearch), onSelect: () => updateSettings(settings.webSearch ? (({ webSearch: _w, ...rest }) => rest)(settings) : { ...settings, webSearch: true }) }] : []),
701 + ...(model?.capabilities.structuredOutput ? [{ key: "json", label: "Structured output", icon: <Braces />, hint: settings.responseFormat?.type === "json_schema" ? "schema" : settings.responseFormat?.type === "json" ? "JSON" : undefined, selected: Boolean(settings.responseFormat), onSelect: () => setStructOpen(true) }] : []),
702 + { key: "system", label: "System prompt", icon: <Terminal />, selected: Boolean(systemPrompt.trim()), onSelect: () => setSysOpen(true) },
703 + "separator",
704 + { key: "temporary", label: "Temporary chat", icon: <EyeOff />, hint: conversation || messages.length ? "New" : undefined, selected: ephemeral, onSelect: toggleTemporary },
705 + ];
706 +
707 + const moreItems: (ActionSheetItem | "separator")[] = [
708 + { key: "config", label: "Model settings", icon: <Settings2 />, hint: activeSettings ? `${activeSettings} set` : undefined, onSelect: () => document.getElementById("chat-model-config")?.click() },
709 + { key: "compare", label: "Compare with…", icon: <Columns3 />, onSelect: openCompareFromComposer, disabled: usableModels.length < 2 },
710 + ...(conversation && !ephemeral
711 + ? ([
712 + { key: "pin", label: conversation.pinned ? "Unpin" : "Pin", icon: <Pin />, onSelect: () => persistConversationMeta({ pinned: !conversation.pinned }) },
713 + { key: "share", label: "Share…", icon: <Share2 />, onSelect: share },
714 + ] as ActionSheetItem[])
715 + : []),
716 + "separator",
717 + { key: "temporary", label: "New temporary chat", icon: <EyeOff />, onSelect: () => router.push("/app/chat?temporary=1") },
718 + { key: "arena", label: "Open Arena", icon: <Swords />, onSelect: () => router.push("/app/arena") },
719 + ...(conversation && !ephemeral ? (["separator", { key: "delete", label: "Delete conversation", icon: <Trash2 />, destructive: true, onSelect: () => setDeleteOpen(true) }] as (ActionSheetItem | "separator")[]) : []),
720 + ];
721 +
722 + const title = ephemeral ? "Temporary chat" : conversation?.title ?? (activeProject ? activeProject.name : "New chat");
345 723
346 724 return (
347 725 <div className="flex h-full min-h-0 flex-col">
348 − {/* Header */}
349 − <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 sm:px-4">
350 − <Button variant="ghost" size="icon-sm" className="md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">
726 + {/* Header — 48 px */}
727 + <header className="flex h-12 shrink-0 items-center gap-1.5 border-b border-border px-2 sm:px-4">
728 + <Button variant="ghost" size="icon-sm" className="tap md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">
351 729 <Menu />
352 730 </Button>
353 − <ModelSelector value={modelKey} onChange={changeModel} size="sm" className="max-w-[46vw] sm:max-w-xs" />
354 − <ModelConfig model={model} settings={settings} onChange={(s) => { setSettings(s); if (conversation) void persistConversationMeta({ settings: s }); }} systemPrompt={systemPrompt} onSystemPromptChange={(v) => { setSystemPrompt(v); if (conversation) void persistConversationMeta({ systemPrompt: v || null }); }} />
355 − <div className="min-w-0 flex-1 truncate text-center text-[13px] font-medium text-fg-muted hidden md:block">{conversation?.title ?? ""}</div>
731 + <div className="flex min-w-0 flex-1 items-center gap-2">
732 + {ephemeral ? (
733 + <Badge variant="warning" className="gap-1 max-w-full">
734 + <EyeOff /> <span className="truncate">Temporary chat — not stored in history</span>
735 + </Badge>
736 + ) : (
737 + <span className="truncate text-[13.5px] font-medium text-fg">{title}</span>
738 + )}
739 + {activeProject && !ephemeral && conversation ? <Badge variant="outline" className="hidden sm:inline-flex">{activeProject.name}</Badge> : null}
740 + </div>
356 741 <div className="ml-auto flex items-center gap-0.5">
357 − {conversation && preferences.showCosts && (conversation.totalCostUsd ?? 0) > 0 ? (
742 + {conversation && !ephemeral && preferences.showCosts && (conversation.totalCostUsd ?? 0) > 0 ? (
358 743 <Tooltip content="Estimated conversation cost">
359 744 <span className="hidden rounded-md bg-bg-muted px-2 py-1 font-mono text-[11px] tabular-nums text-fg-muted sm:inline">≈ {formatUsd(conversation.totalCostUsd, { precise: conversation.totalCostUsd < 0.01 })}</span>
360 745 </Tooltip>
361 746 ) : null}
362 − {conversation ? (
363 − <>
364 − <Tooltip content={conversation.pinned ? "Unpin" : "Pin"}>
365 − <Button variant="ghost" size="icon-sm" onClick={() => persistConversationMeta({ pinned: !conversation.pinned })} aria-label="Pin conversation">
366 − <Pin className={cn(conversation.pinned && "fill-current text-accent")} />
367 − </Button>
368 − </Tooltip>
369 − <Tooltip content="Share">
370 − <Button variant="ghost" size="icon-sm" onClick={share} aria-label="Share conversation">
371 − <Share2 />
747 + {/* Phone: everything under "more" */}
748 + <Button variant="ghost" size="icon-sm" className="tap md:hidden" onClick={() => setMoreOpen(true)} aria-label="More actions">
749 + <MoreHorizontal />
750 + </Button>
751 + {/* Desktop actions */}
752 + <div className="hidden items-center gap-0.5 md:flex">
753 + {usableModels.length > 1 ? (
754 + <Tooltip content="Compare with…">
755 + <Button variant="ghost" size="icon-sm" onClick={openCompareFromComposer} aria-label="Compare models inline" disabled={busy}>
756 + <Columns3 />
372 757 </Button>
373 758 </Tooltip>
374 − <Tooltip content="Delete conversation">
375 − <Button
376 − variant="ghost"
377 − size="icon-sm"
378 − aria-label="Delete conversation"
379 − onClick={async () => {
380 − if (!confirm("Delete this conversation?")) return;
381 − await api(`/api/conversations/${conversation.id}`, { method: "DELETE" });
382 − invalidateConversations();
383 − router.push("/app/chat");
384 − }}
385 − >
386 − <Trash2 />
387 − </Button>
388 − </Tooltip>
389 − </>
390 − ) : (
391 − <Tooltip content="Compare models in the Arena">
392 − <Button asChild variant="ghost" size="icon-sm" aria-label="Open Arena">
393 − <Link href="/app/arena">
394 − <Swords />
395 − </Link>
396 − </Button>
397 − </Tooltip>
398 − )}
759 + ) : null}
760 + {conversation && !ephemeral ? (
761 + <>
762 + <Tooltip content={conversation.pinned ? "Unpin" : "Pin"}>
763 + <Button variant="ghost" size="icon-sm" onClick={() => persistConversationMeta({ pinned: !conversation.pinned })} aria-label="Pin conversation">
764 + <Pin className={cn(conversation.pinned && "fill-current text-accent")} />
765 + </Button>
766 + </Tooltip>
767 + <Tooltip content="Share">
768 + <Button variant="ghost" size="icon-sm" onClick={share} aria-label="Share conversation">
769 + <Share2 />
770 + </Button>
771 + </Tooltip>
772 + <Tooltip content="Delete conversation">
773 + <Button variant="ghost" size="icon-sm" aria-label="Delete conversation" onClick={() => setDeleteOpen(true)}>
774 + <Trash2 />
775 + </Button>
776 + </Tooltip>
777 + </>
778 + ) : (
779 + <>
780 + <Tooltip content={ephemeral ? "Temporary chat (not stored)" : "New temporary chat"}>
781 + <Button variant="ghost" size="icon-sm" aria-label="Temporary chat" onClick={toggleTemporary} className={cn(ephemeral && "text-warning")}>
782 + <EyeOff />
783 + </Button>
784 + </Tooltip>
785 + <Tooltip content="Compare models in the Arena">
786 + <Button asChild variant="ghost" size="icon-sm" aria-label="Open Arena">
787 + <Link href="/app/arena">
788 + <Swords />
789 + </Link>
790 + </Button>
791 + </Tooltip>
792 + </>
793 + )}
794 + </div>
399 795 </div>
400 796 </header>
401 797
402 798 {/* Messages */}
403 799 <div ref={scrollRef} onScroll={onScroll} className="relative min-h-0 flex-1 overflow-y-auto scrollbar-thin">
404 − {messages.length === 0 ? (
405 − <ChatEmptyState model={model} onPick={(t) => setDraft(t)} />
800 + {messages.length === 0 && !compare ? (
801 + <ChatEmptyState model={model} temporary={ephemeral} projectName={activeProject?.name} onPick={(t) => { setDraft(t); composerRef.current?.focus(); }} onAnalyzeFile={() => composerRef.current?.openFilePicker()} onCompare={usableModels.length > 1 ? openCompareFromComposer : undefined} />
406 802 ) : (
407 803 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-0 py-6 sm:px-4">
408 804 {messages.map((m, i) => (
409 − <MessageItem key={m.id} message={m} model={m.modelKey ? modelsByKey.get(m.modelKey) : undefined} live={m.status === "streaming" ? live : null} isLast={i === lastAssistantIdx} wrapCode={preferences.codeWrap} showReasoning={preferences.showReasoning} showCosts={preferences.showCosts} actions={actions} busy={busy} />
805 + <MessageItem key={m.id} message={m} model={m.modelKey ? modelsByKey.get(m.modelKey) : undefined} replacement={m.modelKey === modelKey ? replacement : null} live={m.status === "streaming" ? live : null} isLast={i === lastAssistantIdx} wrapCode={preferences.codeWrap} showReasoning={preferences.showReasoning} showCosts={preferences.showCosts} actions={actions} busy={busy} requestId={turnMeta[m.id]?.requestId} errorAt={turnMeta[m.id]?.errorAt} />
410 806 ))}
807 + {compare ? (
808 + <div className="px-3 sm:px-0">
809 + <CompareInline prompt={compare.prompt} onPromptChange={compare.editable ? (p) => setCompare((c) => (c ? { ...c, prompt: p } : c)) : undefined} systemPrompt={fullSystemPrompt} settings={settings} initialModelKeys={compare.initialModels} conversationId={ephemeral ? null : conversation?.id ?? null} projectId={activeProjectId} onClose={() => setCompare(null)} onAdopted={onAdopted} />
810 + </div>
811 + ) : null}
411 812 </div>
412 813 )}
413 − {!atBottom && messages.length > 0 ? (
414 − <button onClick={() => scrollToBottom(true)} className="sticky bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-border bg-bg-elevated p-1.5 shadow-md hover:bg-bg-subtle" aria-label="Scroll to bottom">
814 + {!atBottom && (messages.length > 0 || compare) ? (
815 + <button onClick={() => scrollToBottom(true)} className="tap sticky bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-border bg-bg-elevated p-2 shadow-md hover:bg-bg-subtle" aria-label="Scroll to bottom">
415 816 <ArrowDown className="size-4" />
416 817 </button>
417 818 ) : null}
418 819 </div>
419 820
420 821 {/* Composer */}
421 − <div className="shrink-0 px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2 sm:px-4">
822 + <div className="shrink-0 px-2 pb-[max(8px,var(--sab))] pt-1.5 sm:px-4 sm:pb-3">
422 823 <div className="mx-auto w-full max-w-3xl">
423 − <Composer model={model} busy={busy} disabled={!model || (!modelUsable && models.length > 0)} enterToSend={preferences.enterToSend} attachments={attachments} onAttachmentsChange={setAttachments} onSend={send} onStop={stop} autoFocus value={draft} onValueChange={setDraft} />
424 − <p className="mt-1.5 hidden text-center text-[11px] text-fg-subtle sm:block">
425 − {model ? (
426 − <>
427 − Responses come straight from {providerName(model.provider)} using your key · <Sparkles className="inline size-3" /> costs are estimates
428 − </>
429 − ) : (
430 − "Choose a model to start"
431 − )}
432 − </p>
824 + {notice && replacement && !ephemeral ? (
825 + <div className="mb-1.5 flex flex-wrap items-center gap-2 rounded-xl bg-warning-soft px-3 py-2 text-[12.5px] text-warning">
826 + <AlertTriangle className="size-3.5 shrink-0" />
827 + <span className="min-w-0 flex-1">
828 + {model?.displayName} is {notice.kind === "deprecated" ? "deprecated" : `retiring on ${notice.shutdownDate}`}.
829 + </span>
830 + <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={() => changeModel(replacement.key)}>
831 + Switch to {replacement.displayName}
832 + </Button>
833 + </div>
834 + ) : null}
835 + <Composer
836 + ref={composerRef}
837 + model={model}
838 + busy={busy}
839 + disabled={composerDisabled}
840 + enterToSend={preferences.enterToSend}
841 + attachments={attachments}
842 + onAttachmentsChange={setAttachments}
843 + onSend={send}
844 + onStop={stop}
845 + autoFocus
846 + value={draft}
847 + onValueChange={setDraft}
848 + extraActions={extraActions}
849 + allowAttachWithoutModel={isAuto && usableModels.length > 0}
850 + projectId={activeProjectId}
851 + placeholder={isAuto ? "Ask anything — Auto picks the model…" : undefined}
852 + topSlot={
853 + <div className="mb-1.5 flex items-center gap-1.5">
854 + <ModelSelector value={modelKey} onChange={(k) => changeModel(k)} size="sm" className="max-w-[46vw] rounded-full sm:max-w-[280px]" buttonLabel={isAuto ? "Auto" : undefined} />
855 + <ModelConfig
856 + model={model}
857 + settings={settings}
858 + onChange={updateSettings}
859 + systemPrompt={systemPrompt}
860 + onSystemPromptChange={updateSystemPrompt}
861 + trigger={
862 + <Button id="chat-model-config" variant="ghost" size="icon-sm" className="tap relative rounded-full" aria-label="Model settings" disabled={!model}>
863 + <Settings2 />
864 + {activeSettings > 0 ? <span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-accent text-[9px] font-semibold text-accent-fg">{activeSettings}</span> : null}
865 + </Button>
866 + }
867 + />
868 + <div className="ml-auto min-w-0">
869 + <ContextIndicator estimate={estimate} cost={cost} model={model} showCost={preferences.showCosts} onNewChat={() => router.push("/app/chat")} onSummarize={model && messages.length > 1 && !ephemeral ? summarize : undefined} summarizing={summarizing} largerModel={largerModel} onSwitchLarger={(k) => changeModel(k)} />
870 + </div>
871 + </div>
872 + }
873 + bottomSlot={
874 + <p className="mt-1.5 hidden text-center text-[11px] text-fg-subtle sm:block">
875 + {ephemeral ? "Temporary chat — messages are not saved; usage is still counted." : model ? <>Responses come straight from {providerName(model.provider)} using your key · costs are estimates</> : isAuto ? "Smart Router recommends a model before each request — nothing is sent without your confirmation." : "Choose a model to start"}
876 + </p>
877 + }
878 + />
433 879 </div>
434 880 </div>
881 +
882 + {/* Sheets & dialogs */}
883 + {routerState ? (
884 + <RouterCard
885 + open
886 + onOpenChange={(o) => {
887 + if (!o) {
888 + restorePending(routerState.pending);
889 + setRouterState(null);
890 + }
891 + }}
892 + result={routerState.result}
893 + mode={routerMode}
894 + onModeChange={(m) => {
895 + setRouterMode(m);
896 + setRouterState((s) => (s ? { ...s, result: routeFor(s.pending.text, s.pending.attachments, m) } : s));
897 + }}
898 + alwaysAuto={alwaysAuto}
899 + onAlwaysAutoChange={setAlwaysAuto}
900 + onUse={(key) => {
901 + const p = routerState.pending;
902 + setRouterState(null);
903 + proceed(key, p);
904 + }}
905 + />
906 + ) : null}
907 + <CostConfirm
908 + open={Boolean(costState)}
909 + onOpenChange={(o) => {
910 + if (!o && costState) {
911 + restorePending(costState.pending);
912 + setCostState(null);
913 + }
914 + }}
915 + model={costState ? modelsByKey.get(costState.modelKey) : undefined}
916 + estimate={costState?.estimate ?? null}
917 + alternatives={costState?.alternatives ?? []}
918 + onSend={() => {
919 + if (!costState) return;
920 + const s = costState;
921 + setCostState(null);
922 + doSend(s.modelKey, s.pending);
923 + }}
924 + onSwitch={(key) => {
925 + if (!costState) return;
926 + const s = costState;
927 + setCostState(null);
928 + doSend(key, s.pending);
929 + }}
930 + />
931 + {pick ? (
932 + <ModelPickerLauncher
933 + ref={pickerRef}
934 + hiddenTrigger
935 + value={modelKey}
936 + onChange={(key) => {
937 + const p = pick;
938 + setPick(null);
939 + p.onPick(key);
940 + }}
941 + />
942 + ) : null}
943 + <SystemPromptSheet open={sysOpen} onOpenChange={setSysOpen} value={systemPrompt} onChange={updateSystemPrompt} projectInstructions={activeProject?.instructions} />
944 + <StructuredOutputSheet open={structOpen} onOpenChange={setStructOpen} settings={settings} onChange={updateSettings} />
945 + <ToolsSheet open={toolsOpen} onOpenChange={setToolsOpen} settings={settings} onChange={updateSettings} supported={Boolean(model?.capabilities.tools)} />
946 + {isMobile ? <ActionSheet open={moreOpen} onOpenChange={setMoreOpen} items={moreItems} title={title} /> : null}
947 + <ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title="Delete this conversation?" description="Messages and attachments are removed permanently. Usage records are kept for your cost history." confirmLabel="Delete" destructive onConfirm={deleteConversation} />
435 948 </div>
436 949 );
437 950 }
added src/components/chat/compare-inline.tsx +392 −0
@@ -0,0 +1,392 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Check, Columns3, Loader2, Play, Square, X } from "lucide-react";
4 +import { useApp } from "@/components/app/store";
5 +import { api, streamEvents, ClientApiError } from "@/lib/client/api";
6 +import type { ChatAdoptResponse, PolyModel, PolyProviderErrorShape } from "@/lib/client/types";
7 +import type { ChatSettings } from "./model-config";
8 +import { ModelSelector } from "./model-selector";
9 +import { MessageError } from "./message-error";
10 +import { Markdown } from "@/components/markdown/markdown";
11 +import { ProviderIcon } from "@/components/brand/provider-icon";
12 +import { Button } from "@/components/ui/button";
13 +import { Textarea } from "@/components/ui/input";
14 +import { Tooltip } from "@/components/ui/tooltip";
15 +import { toast } from "@/components/ui/toast";
16 +import { useIsMobile, useSnapCarousel } from "@/lib/client/hooks";
17 +import { PROVIDERS } from "@/lib/client/providers";
18 +import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";
19 +
20 +/** Wire shape of an Arena response row (subset we need). */
21 +interface ArenaResponseLite {
22 + id: string;
23 + modelKey: string;
24 + content: string;
25 + status: string;
26 + usage?: Record<string, number> | null;
27 + latencyMs?: number | null;
28 + ttftMs?: number | null;
29 + costUsd?: number | null;
30 + error?: { code: string; message: string } | null;
31 +}
32 +
33 +type ArenaEvent =
34 + | { type: "meta"; responseId: string; modelKey: string }
35 + | { type: "text-delta"; text: string }
36 + | { type: "reasoning-delta"; text: string }
37 + | { type: "citation"; citation: { url?: string; title?: string; snippet?: string } }
38 + | { type: "server-tool"; name: string; status: "started" | "completed" }
39 + | { type: "error"; error: PolyProviderErrorShape }
40 + | { type: "done"; response: ArenaResponseLite; status: "complete" | "stopped" | "error" };
41 +
42 +type Status = "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped";
43 +
44 +interface Col {
45 + modelKey: string;
46 + responseId: string | null;
47 + status: Status;
48 + text: string;
49 + reasoning: string;
50 + response: ArenaResponseLite | null;
51 + error: PolyProviderErrorShape | { code: string; message: string } | null;
52 + errorAt?: number;
53 + startedAt: number;
54 + firstTokenAt?: number;
55 +}
56 +
57 +export interface CompareInlineProps {
58 + prompt: string;
59 + /** Editable prompt (new chat / empty state). */
60 + onPromptChange?: (p: string) => void;
61 + systemPrompt?: string | null;
62 + settings?: ChatSettings;
63 + attachmentIds?: string[];
64 + initialModelKeys: string[];
65 + conversationId?: string | null;
66 + projectId?: string | null;
67 + onClose: () => void;
68 + /** Called after "Continue with <model>" succeeded. */
69 + onAdopted: (res: ChatAdoptResponse, modelKey: string) => void;
70 +}
71 +
72 +const MAX = 4;
73 +const MIN = 2;
74 +
75 +function cleanSettings(s: ChatSettings | undefined): ChatSettings | undefined {
76 + if (!s) return undefined;
77 + const { tools: _tools, ...rest } = s;
78 + void _tools;
79 + const out = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))) as ChatSettings;
80 + return Object.keys(out).length ? out : undefined;
81 +}
82 +
83 +function emptyCol(modelKey: string): Col {
84 + return { modelKey, responseId: null, status: "waiting", text: "", reasoning: "", response: null, error: null, startedAt: Date.now() };
85 +}
86 +
87 +/**
88 + * Inline "Compare with…" — 2–4 models answer the same prompt inside the chat. Runs through the Arena API
89 + * (so the comparison is stored as an Arena session) and lets the user continue the conversation with any
90 + * answer via POST /api/chat/adopt.
91 + */
92 +export function CompareInline({ prompt, onPromptChange, systemPrompt, settings, attachmentIds, initialModelKeys, conversationId, projectId, onClose, onAdopted }: CompareInlineProps) {
93 + const { modelsByKey, connectedProviders, preferences } = useApp();
94 + const isMobile = useIsMobile();
95 + const [selected, setSelected] = React.useState<string[]>(() => initialModelKeys.filter((k) => modelsByKey.has(k)).slice(0, MAX));
96 + const [columns, setColumns] = React.useState<Col[]>([]);
97 + const [running, setRunning] = React.useState(false);
98 + const [adopting, setAdopting] = React.useState<string | null>(null);
99 + const mapRef = React.useRef(new Map<string, Col>());
100 + const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
101 + const abortRef = React.useRef<AbortController | null>(null);
102 + const { ref: rowRef, index, scrollTo } = useSnapCarousel<HTMLDivElement>(columns.length);
103 +
104 + const flush = React.useCallback(() => {
105 + timer.current = null;
106 + setColumns([...mapRef.current.values()].map((c) => ({ ...c })));
107 + }, []);
108 + const schedule = React.useCallback(() => {
109 + if (!timer.current) timer.current = setTimeout(flush, 40);
110 + }, [flush]);
111 + React.useEffect(
112 + () => () => {
113 + if (timer.current) clearTimeout(timer.current);
114 + abortRef.current?.abort();
115 + },
116 + [],
117 + );
118 +
119 + const selectedSet = React.useMemo(() => new Set(selected), [selected]);
120 + const toggle = (key: string) =>
121 + setSelected((s) => {
122 + if (s.includes(key)) return s.filter((k) => k !== key);
123 + if (s.length >= MAX) {
124 + toast.warning(`Up to ${MAX} models`, "Remove one before adding another.");
125 + return s;
126 + }
127 + return [...s, key];
128 + });
129 + const disconnected = selected.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m) && !connectedProviders.has(m!.provider));
130 + const canRun = !running && selected.length >= MIN && prompt.trim().length > 0 && disconnected.length === 0;
131 + const reason = running ? null : selected.length < MIN ? `Pick at least ${MIN} models` : !prompt.trim() ? "Write a prompt first" : disconnected.length ? `${disconnected.map((m) => PROVIDERS[m.provider].shortName).join(", ")} not connected` : null;
132 +
133 + const streamOne = async (sessionId: string, modelKey: string, signal: AbortSignal) => {
134 + const col = () => mapRef.current.get(modelKey);
135 + try {
136 + await streamEvents(
137 + "/api/arena/stream",
138 + { sessionId, modelKey },
139 + (raw) => {
140 + const c = col();
141 + if (!c) return;
142 + const ev = raw as unknown as ArenaEvent;
143 + switch (ev.type) {
144 + case "meta":
145 + c.responseId = ev.responseId;
146 + break;
147 + case "text-delta":
148 + if (!c.firstTokenAt) c.firstTokenAt = Date.now();
149 + c.text += ev.text;
150 + c.status = "streaming";
151 + break;
152 + case "reasoning-delta":
153 + if (!c.firstTokenAt) c.firstTokenAt = Date.now();
154 + c.reasoning += ev.text;
155 + if (c.status === "waiting") c.status = "thinking";
156 + break;
157 + case "error":
158 + c.error = ev.error;
159 + c.errorAt = Date.now();
160 + break;
161 + case "done":
162 + c.response = ev.response;
163 + c.responseId = ev.response.id;
164 + c.status = ev.status === "complete" ? "done" : ev.status === "stopped" ? "stopped" : "error";
165 + if (ev.response.error && !c.error) {
166 + c.error = ev.response.error;
167 + c.errorAt = Date.now();
168 + }
169 + break;
170 + default:
171 + break;
172 + }
173 + schedule();
174 + },
175 + signal,
176 + );
177 + const c = col();
178 + if (c && (c.status === "waiting" || c.status === "thinking" || c.status === "streaming")) c.status = c.error ? "error" : "stopped";
179 + } catch (e) {
180 + const c = col();
181 + if (!c) return;
182 + if ((e as Error).name === "AbortError") c.status = "stopped";
183 + else {
184 + const err = e as ClientApiError;
185 + c.status = "error";
186 + c.error = { code: err.code ?? "HTTP_ERROR", message: err.message };
187 + c.errorAt = Date.now();
188 + }
189 + }
190 + flush();
191 + };
192 +
193 + const run = async () => {
194 + if (!canRun) return;
195 + setRunning(true);
196 + const keys = [...selected];
197 + mapRef.current = new Map(keys.map((k) => [k, emptyCol(k)]));
198 + flush();
199 + const controller = new AbortController();
200 + abortRef.current = controller;
201 + try {
202 + const res = await api<{ session: { id: string } }>("/api/arena", {
203 + method: "POST",
204 + json: { prompt: prompt.trim(), systemPrompt: systemPrompt?.trim() || undefined, modelKeys: keys, settings: cleanSettings(settings), attachmentIds: attachmentIds?.length ? attachmentIds.slice(0, 6) : undefined },
205 + });
206 + await Promise.allSettled(keys.map((k) => streamOne(res.session.id, k, controller.signal)));
207 + } catch (e) {
208 + const err = e as ClientApiError;
209 + toast.error("Could not start the comparison", err.message);
210 + mapRef.current = new Map();
211 + flush();
212 + } finally {
213 + setRunning(false);
214 + abortRef.current = null;
215 + }
216 + };
217 +
218 + const stop = () => abortRef.current?.abort();
219 +
220 + const adopt = async (c: Col) => {
221 + if (!c.responseId && !c.text) return;
222 + setAdopting(c.modelKey);
223 + try {
224 + const res = await api<ChatAdoptResponse>("/api/chat/adopt", {
225 + method: "POST",
226 + json: { conversationId: conversationId ?? undefined, modelKey: c.modelKey, arenaResponseId: c.responseId ?? undefined, content: c.responseId ? undefined : c.text, userText: conversationId ? undefined : prompt.trim(), systemPrompt: conversationId ? undefined : systemPrompt ?? null, settings: conversationId ? undefined : cleanSettings(settings), projectId: conversationId ? undefined : projectId ?? null },
227 + });
228 + onAdopted(res, c.modelKey);
229 + } catch (e) {
230 + toast.error("Could not continue with this answer", (e as Error).message);
231 + } finally {
232 + setAdopting(null);
233 + }
234 + };
235 +
236 + const allSettled = columns.length > 0 && columns.every((c) => c.status === "done" || c.status === "error" || c.status === "stopped");
237 +
238 + return (
239 + <section className="rounded-2xl border border-border bg-bg-elevated shadow-sm" aria-label="Compare models" data-no-edge-swipe>
240 + {/* Header */}
241 + <header className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
242 + <span className="flex size-7 items-center justify-center rounded-md bg-accent-soft text-accent">
243 + <Columns3 className="size-4" />
244 + </span>
245 + <h3 className="text-[14px] font-semibold tracking-tight">Compare with…</h3>
246 + <span className="hidden text-[12px] text-fg-muted sm:inline">{selected.length}/{MAX} models</span>
247 + <div className="ml-auto flex items-center gap-1.5">
248 + {running ? (
249 + <Button size="sm" variant="secondary" onClick={stop}>
250 + <Square className="size-3.5 fill-current" /> Stop
251 + </Button>
252 + ) : (
253 + <Tooltip content={reason ?? "Run the comparison"}>
254 + <span className="inline-flex">
255 + <Button size="sm" variant="accent" disabled={!canRun} onClick={run}>
256 + <Play className="size-3.5 fill-current" /> {columns.length ? "Run again" : "Run"}
257 + </Button>
258 + </span>
259 + </Tooltip>
260 + )}
261 + <Button size="icon-sm" variant="ghost" onClick={onClose} aria-label="Close comparison" disabled={running}>
262 + <X />
263 + </Button>
264 + </div>
265 + </header>
266 +
267 + {/* Setup */}
268 + <div className="space-y-2 px-3 py-2.5">
269 + <div className="flex flex-wrap items-center gap-1.5">
270 + {selected.map((k) => {
271 + const m = modelsByKey.get(k);
272 + return (
273 + <span key={k} className="inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-bg-subtle pl-2 pr-1 text-[12.5px]">
274 + <ProviderIcon provider={m?.provider ?? k.split("/")[0]} size={13} />
275 + <span className="max-w-[140px] truncate">{m?.displayName ?? k}</span>
276 + <button type="button" onClick={() => toggle(k)} disabled={running} className="tap rounded-full p-0.5 text-fg-subtle hover:text-danger" aria-label={`Remove ${m?.displayName ?? k}`}>
277 + <X className="size-3" />
278 + </button>
279 + </span>
280 + );
281 + })}
282 + {!running ? <ModelSelector value={null} multiple selected={selectedSet} onToggle={toggle} size="sm" buttonLabel={selected.length < MAX ? "+ Add model" : "Models"} className="h-8 rounded-full" /> : null}
283 + </div>
284 + {onPromptChange ? (
285 + <Textarea value={prompt} onChange={(e) => onPromptChange(e.target.value)} placeholder="One prompt for every model…" className="min-h-[64px] text-[14px]" disabled={running} aria-label="Comparison prompt" />
286 + ) : (
287 + <p className="line-clamp-3 rounded-lg bg-bg-subtle px-3 py-2 text-[13px] text-fg-muted" title={prompt}>
288 + {prompt}
289 + </p>
290 + )}
291 + {reason && !columns.length ? <p className="text-[12px] text-fg-subtle">{reason}.</p> : null}
292 + </div>
293 +
294 + {/* Results */}
295 + {columns.length ? (
296 + isMobile ? (
297 + <div className="border-t border-border">
298 + <div className="sticky top-0 z-10 flex gap-1 overflow-x-auto bg-bg-elevated/95 px-2 py-2 backdrop-blur scrollbar-none" role="tablist">
299 + {columns.map((c, i) => {
300 + const m = modelsByKey.get(c.modelKey);
301 + return (
302 + <button key={c.modelKey} role="tab" aria-selected={index === i} onClick={() => scrollTo(i)} className={cn("inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium", index === i ? "border-fg bg-fg text-bg" : "border-border text-fg-muted")}>
303 + <StatusDot status={c.status} />
304 + <span className="max-w-[140px] truncate">{m?.displayName ?? c.modelKey}</span>
305 + </button>
306 + );
307 + })}
308 + </div>
309 + <div ref={rowRef} className="snap-row">
310 + {columns.map((c) => (
311 + <div key={c.modelKey} className="px-3 pb-3">
312 + <Panel col={c} model={modelsByKey.get(c.modelKey)} wrapCode={preferences.codeWrap} showCosts={preferences.showCosts} onAdopt={allSettled ? () => adopt(c) : undefined} adopting={adopting === c.modelKey} />
313 + </div>
314 + ))}
315 + </div>
316 + </div>
317 + ) : (
318 + <div className={cn("grid gap-3 border-t border-border p-3", columns.length === 2 ? "md:grid-cols-2" : columns.length === 3 ? "md:grid-cols-3" : "md:grid-cols-2 xl:grid-cols-4")}>
319 + {columns.map((c) => (
320 + <Panel key={c.modelKey} col={c} model={modelsByKey.get(c.modelKey)} wrapCode={preferences.codeWrap} showCosts={preferences.showCosts} onAdopt={allSettled ? () => adopt(c) : undefined} adopting={adopting === c.modelKey} />
321 + ))}
322 + </div>
323 + )
324 + ) : null}
325 + {allSettled ? <p className="px-3 pb-3 text-[12px] text-fg-subtle">Saved to the Arena history. Pick an answer to continue the conversation with that model.</p> : null}
326 + </section>
327 + );
328 +}
329 +
330 +const STATUS: Record<Status, { label: string; dot: string; pulse: boolean }> = {
331 + waiting: { label: "Waiting", dot: "bg-fg-subtle", pulse: true },
332 + thinking: { label: "Thinking", dot: "bg-accent", pulse: true },
333 + streaming: { label: "Streaming", dot: "bg-info", pulse: true },
334 + done: { label: "Done", dot: "bg-success", pulse: false },
335 + error: { label: "Failed", dot: "bg-danger", pulse: false },
336 + stopped: { label: "Stopped", dot: "bg-warning", pulse: false },
337 +};
338 +
339 +function StatusDot({ status }: { status: Status }) {
340 + const s = STATUS[status];
341 + return <span className={cn("inline-block size-2 shrink-0 rounded-full", s.dot, s.pulse && "animate-pulse-soft")} aria-hidden />;
342 +}
343 +
344 +function Panel({ col: c, model, wrapCode, showCosts, onAdopt, adopting }: { col: Col; model?: PolyModel; wrapCode?: boolean; showCosts?: boolean; onAdopt?: () => void; adopting?: boolean }) {
345 + const name = model?.displayName ?? c.modelKey.split("/").slice(1).join("/");
346 + const live = c.status === "waiting" || c.status === "thinking" || c.status === "streaming";
347 + const u = (c.response?.usage ?? null) as { inputTokens?: number; outputTokens?: number } | null;
348 + const gen = c.response?.latencyMs ? Math.max(1, c.response.latencyMs - (c.response.ttftMs ?? 0)) : null;
349 + const tps = u?.outputTokens && gen ? Math.round((u.outputTokens / gen) * 1000) : null;
350 + return (
351 + <article className="flex min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-bg" aria-label={`${name} response`} aria-busy={live}>
352 + <header className="flex items-center gap-2 border-b border-border px-3 py-2">
353 + <ProviderIcon provider={model?.provider ?? c.modelKey.split("/")[0]} size={14} />
354 + <span className="min-w-0 flex-1 truncate text-[13px] font-semibold">{name}</span>
355 + <span className="inline-flex items-center gap-1.5 text-[11px] text-fg-subtle">
356 + <StatusDot status={c.status} /> {STATUS[c.status].label}
357 + </span>
358 + </header>
359 + <div className="max-h-[420px] min-h-[96px] overflow-y-auto px-3 py-2 text-[14px] scrollbar-thin">
360 + {c.status === "waiting" && !c.text ? (
361 + <p className="flex items-center gap-2 text-[13px] text-fg-muted">
362 + <Loader2 className="size-3.5 animate-spin" /> Waiting for the model…
363 + </p>
364 + ) : null}
365 + {c.status === "thinking" && !c.text ? <p className="text-[13px] text-fg-muted">Thinking…</p> : null}
366 + {c.text ? <Markdown content={c.text} wrap={wrapCode} streaming={live} className="text-[14px]" /> : null}
367 + {c.error && !live ? (
368 + <div className="mt-2">
369 + <MessageError error={c.error} provider={model?.provider ?? c.modelKey.split("/")[0]} since={c.errorAt} compact />
370 + </div>
371 + ) : null}
372 + </div>
373 + <footer className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] tabular-nums text-fg-subtle">
374 + {c.response?.ttftMs ? <span title="Time to first token">TTFT {formatMs(c.response.ttftMs)}</span> : null}
375 + {c.response?.latencyMs ? <span>{formatMs(c.response.latencyMs)}</span> : null}
376 + {tps ? <span>{tps} tok/s</span> : null}
377 + {u ? (
378 + <span>
379 + {formatTokens(u.inputTokens ?? 0)} in · {formatTokens(u.outputTokens ?? 0)} out
380 + </span>
381 + ) : null}
382 + {showCosts && c.response?.costUsd !== null && c.response?.costUsd !== undefined ? <span>≈ {formatUsd(c.response.costUsd, { precise: c.response.costUsd < 0.01 })}</span> : null}
383 + <span className="flex-1" />
384 + {onAdopt && c.status === "done" && c.text ? (
385 + <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={onAdopt} loading={adopting}>
386 + <Check /> Continue with {name}
387 + </Button>
388 + ) : null}
389 + </footer>
390 + </article>
391 + );
392 +}
added src/components/chat/composer-sheets.tsx +187 −0
@@ -0,0 +1,187 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Braces } from "lucide-react";
4 +import { ResponsiveDialog } from "@/components/ui/sheet";
5 +import { Button } from "@/components/ui/button";
6 +import { Textarea } from "@/components/ui/input";
7 +import { Segmented } from "@/components/ui/segmented";
8 +import { Switch } from "@/components/ui/switch";
9 +import type { ChatSettings } from "./model-config";
10 +import { cn } from "@/lib/utils";
11 +
12 +type FormatMode = "text" | "json" | "json_schema";
13 +
14 +const DEFAULT_SCHEMA = '{\n "type": "object",\n "properties": {\n "answer": { "type": "string" }\n },\n "required": ["answer"],\n "additionalProperties": false\n}';
15 +
16 +/** Structured output: JSON object / JSON schema (with an editor) — only offered when the model supports it. */
17 +export function StructuredOutputSheet({ open, onOpenChange, settings, onChange }: { open: boolean; onOpenChange: (o: boolean) => void; settings: ChatSettings; onChange: (s: ChatSettings) => void }) {
18 + const [mode, setMode] = React.useState<FormatMode>(settings.responseFormat?.type ?? "text");
19 + const [schemaText, setSchemaText] = React.useState(() => (settings.responseFormat?.schema ? JSON.stringify(settings.responseFormat.schema, null, 2) : DEFAULT_SCHEMA));
20 + const [error, setError] = React.useState<string | null>(null);
21 +
22 + React.useEffect(() => {
23 + if (open) {
24 + // eslint-disable-next-line react-hooks/set-state-in-effect
25 + setMode(settings.responseFormat?.type ?? "text");
26 + setSchemaText(settings.responseFormat?.schema ? JSON.stringify(settings.responseFormat.schema, null, 2) : DEFAULT_SCHEMA);
27 + setError(null);
28 + }
29 + }, [open, settings.responseFormat]);
30 +
31 + const apply = () => {
32 + const next = { ...settings };
33 + if (mode === "text") {
34 + delete next.responseFormat;
35 + } else if (mode === "json") {
36 + next.responseFormat = { type: "json" };
37 + } else {
38 + try {
39 + const parsed = JSON.parse(schemaText);
40 + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Schema must be a JSON object");
41 + next.responseFormat = { type: "json_schema", schema: parsed, strict: true };
42 + } catch (e) {
43 + setError((e as Error).message);
44 + return;
45 + }
46 + }
47 + onChange(next);
48 + onOpenChange(false);
49 + };
50 +
51 + return (
52 + <ResponsiveDialog
53 + open={open}
54 + onOpenChange={onOpenChange}
55 + title={
56 + <span className="inline-flex items-center gap-2">
57 + <Braces className="size-4" /> Structured output
58 + </span>
59 + }
60 + description="Ask the model to answer with JSON. Strict schema mode validates the shape when the provider supports it."
61 + size="md"
62 + footer={
63 + <div className="flex gap-2 sm:justify-end">
64 + <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => onOpenChange(false)}>
65 + Cancel
66 + </Button>
67 + <Button className="flex-1 sm:flex-none" onClick={apply}>
68 + Apply
69 + </Button>
70 + </div>
71 + }
72 + >
73 + <div className="space-y-4 pt-1">
74 + <Segmented<FormatMode> value={mode} onChange={setMode} fill ariaLabel="Response format" options={[{ value: "text", label: "Text" }, { value: "json", label: "JSON object" }, { value: "json_schema", label: "JSON schema" }]} />
75 + {mode === "json_schema" ? (
76 + <div className="space-y-1.5">
77 + <Textarea
78 + value={schemaText}
79 + onChange={(e) => {
80 + setSchemaText(e.target.value);
81 + try {
82 + JSON.parse(e.target.value);
83 + setError(null);
84 + } catch (err) {
85 + setError((err as Error).message);
86 + }
87 + }}
88 + className={cn("min-h-[220px] font-mono text-[12.5px] leading-5", error && "border-danger focus:border-danger")}
89 + spellCheck={false}
90 + aria-label="JSON schema"
91 + />
92 + {error ? <p className="text-xs text-danger">{error}</p> : <p className="text-xs text-fg-subtle">Object root; keep `additionalProperties: false` for strict mode.</p>}
93 + </div>
94 + ) : mode === "json" ? (
95 + <p className="text-[13px] text-fg-muted">The model returns a single JSON object. Mention the keys you expect in your prompt.</p>
96 + ) : (
97 + <p className="text-[13px] text-fg-muted">Plain Markdown answers (default).</p>
98 + )}
99 + </div>
100 + </ResponsiveDialog>
101 + );
102 +}
103 +
104 +/** System prompt editor (conversation-level instructions). */
105 +export function SystemPromptSheet({ open, onOpenChange, value, onChange, projectInstructions }: { open: boolean; onOpenChange: (o: boolean) => void; value: string; onChange: (v: string) => void; projectInstructions?: string | null }) {
106 + const [draft, setDraft] = React.useState(value);
107 + React.useEffect(() => {
108 + // eslint-disable-next-line react-hooks/set-state-in-effect
109 + if (open) setDraft(value);
110 + }, [open, value]);
111 + return (
112 + <ResponsiveDialog
113 + open={open}
114 + onOpenChange={onOpenChange}
115 + title="System prompt"
116 + description="Instructions that apply to the whole conversation. They are sent before every message."
117 + size="md"
118 + snap="half"
119 + footer={
120 + <div className="flex items-center gap-2">
121 + <span className="text-[12px] tabular-nums text-fg-subtle">{draft.length.toLocaleString()} chars</span>
122 + <div className="flex-1" />
123 + <Button variant="ghost" onClick={() => onOpenChange(false)}>
124 + Cancel
125 + </Button>
126 + <Button
127 + onClick={() => {
128 + onChange(draft);
129 + onOpenChange(false);
130 + }}
131 + >
132 + Save
133 + </Button>
134 + </div>
135 + }
136 + >
137 + <div className="space-y-3 pt-1">
138 + {projectInstructions ? (
139 + <div className="rounded-lg bg-bg-subtle px-3 py-2 text-[12.5px] text-fg-muted">
140 + <span className="font-medium text-fg">Project instructions</span> are prepended automatically:
141 + <p className="mt-1 line-clamp-3 whitespace-pre-wrap">{projectInstructions}</p>
142 + </div>
143 + ) : null}
144 + <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="You are a concise assistant that answers in French…" className="min-h-[180px]" autoFocus />
145 + </div>
146 + </ResponsiveDialog>
147 + );
148 +}
149 +
150 +const BUILTIN_TOOLS: { id: string; label: string; description: string }[] = [
151 + { id: "calculator", label: "Calculator", description: "Exact arithmetic for any non-trivial math." },
152 + { id: "clock", label: "Clock", description: "Current date and time in any time zone." },
153 + { id: "random", label: "Random", description: "Cryptographically secure random integers." },
154 +];
155 +
156 +/** Built-in PolyLLM tools (run server-side, side-effect free) — toggles mirror `settings.tools`. */
157 +export function ToolsSheet({ open, onOpenChange, settings, onChange, supported }: { open: boolean; onOpenChange: (o: boolean) => void; settings: ChatSettings; onChange: (s: ChatSettings) => void; supported: boolean }) {
158 + const on = new Set(settings.tools ?? []);
159 + const toggle = (id: string) => {
160 + const next = new Set(on);
161 + if (next.has(id)) next.delete(id);
162 + else next.add(id);
163 + const tools = [...next];
164 + const out = { ...settings };
165 + if (tools.length) out.tools = tools;
166 + else {
167 + delete out.tools;
168 + delete out.toolChoice;
169 + }
170 + onChange(out);
171 + };
172 + return (
173 + <ResponsiveDialog open={open} onOpenChange={onOpenChange} title="Tools" description={supported ? "Built-in PolyLLM tools the model may call. They run server-side and have no side effects." : "This model does not support tool calling."} size="sm">
174 + <ul className="divide-y divide-hairline pt-1">
175 + {BUILTIN_TOOLS.map((t) => (
176 + <li key={t.id} className="flex min-h-[52px] items-center gap-3 py-2">
177 + <span className="min-w-0 flex-1">
178 + <span className="block text-[14px] font-medium">{t.label}</span>
179 + <span className="block text-[12px] text-fg-muted">{t.description}</span>
180 + </span>
181 + <Switch checked={on.has(t.id)} onCheckedChange={() => toggle(t.id)} disabled={!supported} aria-label={`Enable ${t.label}`} />
182 + </li>
183 + ))}
184 + </ul>
185 + </ResponsiveDialog>
186 + );
187 +}
modified src/components/chat/composer.tsx +321 −83
@@ -1,12 +1,17 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { ArrowUp, Paperclip, Square, ImagePlus } from "lucide-react";
3 +import { ArrowUp, Camera, ChevronDown, ChevronUp, ClipboardPaste, FileText, ImagePlus, Library, Mic, MicOff, Plus, Square } from "lucide-react";
4 4 import type { PolyModel } from "@/lib/client/types";
5 5 import { Button } from "@/components/ui/button";
6 6 import { Tooltip } from "@/components/ui/tooltip";
7 7 import { Kbd } from "@/components/ui/misc";
8 8 import { toast } from "@/components/ui/toast";
9 +import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";
10 +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
11 +import { FileLibraryPicker, type PickedAttachment } from "@/components/library/file-library-picker";
12 +import { useIsCoarsePointer, useIsMobile } from "@/lib/client/hooks";
9 13 import { AttachmentChip } from "./message";
14 +import { useSpeechDictation } from "./use-speech";
10 15 import { cn } from "@/lib/utils";
11 16
12 17 export interface PendingAttachment {
@@ -19,6 +24,26 @@ export interface PendingAttachment {
19 24 height?: number | null;
20 25 }
21 26
27 +/** Extra entries for the `+` menu (chat adds tools, web search, structured output, system prompt, temporary chat). */
28 +export interface ComposerAction {
29 + key: string;
30 + label: React.ReactNode;
31 + icon?: React.ReactNode;
32 + hint?: React.ReactNode;
33 + onSelect: () => void;
34 + selected?: boolean;
35 + disabled?: boolean;
36 + destructive?: boolean;
37 +}
38 +
39 +export interface ComposerHandle {
40 + focus(): void;
41 + /** Open the native file picker (`kind` narrows the accepted types). */
42 + openFilePicker(kind?: "image" | "document" | "camera"): void;
43 + /** Insert text at the caret (or append) and focus. */
44 + insert(text: string): void;
45 +}
46 +
22 47 interface Props {
23 48 model?: PolyModel;
24 49 disabled?: boolean;
@@ -31,53 +56,156 @@ interface Props {
31 56 onStop: () => void;
32 57 leftSlot?: React.ReactNode;
33 58 rightSlot?: React.ReactNode;
59 + /** Row rendered above the input box (model pill, context indicator…). */
60 + topSlot?: React.ReactNode;
61 + /** Row rendered below the input box (hints, banners). */
62 + bottomSlot?: React.ReactNode;
34 63 autoFocus?: boolean;
35 64 value?: string;
36 65 onValueChange?: (v: string) => void;
66 + /** Extra `+` menu entries, appended after the attachment actions. */
67 + extraActions?: (ComposerAction | "separator")[];
68 + /** Hide the microphone even when the browser supports dictation. */
69 + voice?: boolean;
70 + /** Maximum visible lines before the textarea scrolls (default 6). */
71 + maxLines?: number;
72 + /** Allow uploads even without a model (e.g. AUTO routing). */
73 + allowAttachWithoutModel?: boolean;
74 + /** Which library the "From library" picker should target. */
75 + projectId?: string | null;
37 76 }
38 77
39 −export function Composer({ model, disabled, busy, enterToSend = true, placeholder, attachments, onAttachmentsChange, onSend, onStop, leftSlot, rightSlot, autoFocus, value, onValueChange }: Props) {
78 +const LINE_PX = 24;
79 +const PAD_Y = 20; // pt-2.5 + pb-2.5
80 +
81 +const TEXT_ACCEPT = ".txt,.md,.csv,.json,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.kt,.swift,.rb,.php,.c,.cpp,.h,.cs,.sh,.yaml,.yml,.toml,.sql,.html,.css,.xml";
82 +const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
83 +
84 +export const Composer = React.forwardRef<ComposerHandle, Props>(function Composer(
85 + { model, disabled, busy, enterToSend = true, placeholder, attachments, onAttachmentsChange, onSend, onStop, leftSlot, rightSlot, topSlot, bottomSlot, autoFocus, value, onValueChange, extraActions, voice = true, maxLines = 6, allowAttachWithoutModel, projectId },
86 + ref,
87 +) {
40 88 const [inner, setInner] = React.useState("");
41 89 const text = value ?? inner;
42 90 const setText = onValueChange ?? setInner;
43 − const ref = React.useRef<HTMLTextAreaElement>(null);
91 + const textRef = React.useRef(text);
92 + React.useEffect(() => {
93 + textRef.current = text;
94 + }, [text]);
95 + const taRef = React.useRef<HTMLTextAreaElement>(null);
44 96 const fileRef = React.useRef<HTMLInputElement>(null);
97 + const cameraRef = React.useRef<HTMLInputElement>(null);
98 + const [fileAccept, setFileAccept] = React.useState<string>("");
45 99 const [uploading, setUploading] = React.useState(0);
46 100 const [dragging, setDragging] = React.useState(false);
101 + const [collapsed, setCollapsed] = React.useState(false);
102 + const [multiline, setMultiline] = React.useState(false);
103 + const [menuOpen, setMenuOpen] = React.useState(false);
104 + const [libraryOpen, setLibraryOpen] = React.useState(false);
105 + const isMobile = useIsMobile();
106 + const coarse = useIsCoarsePointer();
47 107
48 − const canAttach = Boolean(model);
49 − const canImages = Boolean(model?.capabilities.vision);
50 − const canFiles = true; // text-like files are always inlined; PDFs need `files`
108 + const canAttach = Boolean(model) || Boolean(allowAttachWithoutModel);
109 + const canImages = model ? Boolean(model.capabilities.vision) : Boolean(allowAttachWithoutModel);
110 + const canPdf = model ? Boolean(model.capabilities.files) : Boolean(allowAttachWithoutModel);
51 111
52 − React.useEffect(() => {
53 − const el = ref.current;
112 + // --- auto-grow 1 → maxLines, then scroll -------------------------------------------------------
113 + React.useLayoutEffect(() => {
114 + const el = taRef.current;
54 115 if (!el) return;
55 116 el.style.height = "0px";
56 − el.style.height = `${Math.min(280, Math.max(44, el.scrollHeight))}px`;
57 − }, [text]);
117 + const natural = el.scrollHeight;
118 + const lines = Math.max(1, Math.round((natural - PAD_Y) / LINE_PX));
119 + const maxH = maxLines * LINE_PX + PAD_Y;
120 + const target = collapsed ? LINE_PX + PAD_Y : Math.min(maxH, Math.max(LINE_PX + PAD_Y, natural));
121 + el.style.height = `${target}px`;
122 + el.style.overflowY = natural > target ? "auto" : "hidden";
123 + setMultiline(lines > 1);
124 + if (lines <= 1 && collapsed) setCollapsed(false);
125 + }, [text, maxLines, collapsed]);
58 126
59 127 React.useEffect(() => {
60 − if (autoFocus) ref.current?.focus();
61 − }, [autoFocus]);
128 + if (autoFocus && !coarse) taRef.current?.focus();
129 + }, [autoFocus, coarse]);
62 130
131 + // --- imperative handle ----------------------------------------------------------------------------
132 + const insertAtCaret = React.useCallback(
133 + (snippet: string) => {
134 + const el = taRef.current;
135 + const cur = textRef.current;
136 + if (!el) {
137 + setText(cur ? `${cur}\n${snippet}` : snippet);
138 + return;
139 + }
140 + const start = el.selectionStart ?? cur.length;
141 + const end = el.selectionEnd ?? cur.length;
142 + const before = cur.slice(0, start);
143 + const after = cur.slice(end);
144 + const next = `${before}${before && !/\s$/.test(before) ? "\n" : ""}${snippet}${after}`;
145 + setText(next);
146 + requestAnimationFrame(() => {
147 + el.focus();
148 + const pos = next.length - after.length;
149 + el.setSelectionRange(pos, pos);
150 + });
151 + },
152 + [setText],
153 + );
154 +
155 + const openFilePicker = React.useCallback(
156 + (kind?: "image" | "document" | "camera") => {
157 + if (kind === "camera") {
158 + cameraRef.current?.click();
159 + return;
160 + }
161 + const accept = kind === "image" ? IMAGE_ACCEPT : kind === "document" ? [canPdf ? "application/pdf" : "", TEXT_ACCEPT].filter(Boolean).join(",") : [canImages ? IMAGE_ACCEPT : "", canPdf ? "application/pdf" : "", TEXT_ACCEPT].filter(Boolean).join(",");
162 + setFileAccept(accept);
163 + // Let React commit the new `accept` before opening the dialog.
164 + requestAnimationFrame(() => fileRef.current?.click());
165 + },
166 + [canImages, canPdf],
167 + );
168 +
169 + React.useImperativeHandle(ref, () => ({ focus: () => taRef.current?.focus(), openFilePicker, insert: insertAtCaret }), [openFilePicker, insertAtCaret]);
170 +
171 + // --- voice dictation ------------------------------------------------------------------------------
172 + const baseRef = React.useRef("");
173 + const speech = useSpeechDictation((final, interim) => {
174 + const base = baseRef.current;
175 + const joiner = base && !/\s$/.test(base) ? " " : "";
176 + setText(`${base}${joiner}${final}${final && interim ? " " : ""}${interim}`);
177 + });
178 + const toggleVoice = () => {
179 + if (!speech.listening) baseRef.current = textRef.current;
180 + speech.toggle();
181 + };
182 + React.useEffect(() => {
183 + if (speech.error) toast.warning("Dictation unavailable", speech.error);
184 + }, [speech.error]);
185 +
186 + // --- submit ---------------------------------------------------------------------------------------
63 187 const submit = () => {
64 188 if (busy) return;
65 189 const t = text.trim();
66 190 if (!t && attachments.length === 0) return;
191 + if (speech.listening) speech.stop();
67 192 onSend(t);
68 193 setText("");
194 + setCollapsed(false);
69 195 };
70 196
197 + // --- uploads --------------------------------------------------------------------------------------
71 198 const upload = async (files: FileList | File[]) => {
72 199 const list = Array.from(files).slice(0, 10 - attachments.length);
73 200 if (!list.length) return;
201 + let current = attachments;
74 202 for (const f of list) {
75 203 const isImage = f.type.startsWith("image/");
76 204 if (isImage && !canImages) {
77 205 toast.warning("This model has no vision", `${model?.displayName ?? "The selected model"} cannot read images. Pick a vision-capable model.`);
78 206 continue;
79 207 }
80 − if (f.type === "application/pdf" && !model?.capabilities.files) {
208 + if (f.type === "application/pdf" && !canPdf) {
81 209 toast.warning("PDF not supported by this model", "Pick a model with file input (e.g. Claude, GPT-5.x, Gemini).");
82 210 continue;
83 211 }
@@ -88,11 +216,12 @@ export function Composer({ model, disabled, busy, enterToSend = true, placeholde
88 216 setUploading((n) => n + 1);
89 217 try {
90 218 const fd = new FormData();
91 − fd.append("file", f, f.name);
219 + fd.append("file", f, f.name || (isImage ? "photo.jpg" : "file"));
92 220 const res = await fetch("/api/attachments", { method: "POST", body: fd });
93 221 const data = await res.json();
94 222 if (!res.ok) throw new Error(data?.error?.message ?? "Upload failed");
95 − onAttachmentsChange([...attachments, data.attachment]);
223 + current = [...current, data.attachment];
224 + onAttachmentsChange(current);
96 225 } catch (e) {
97 226 toast.error("Upload failed", (e as Error).message);
98 227 } finally {
@@ -111,62 +240,144 @@ export function Composer({ model, disabled, busy, enterToSend = true, placeholde
111 240 }
112 241 };
113 242
114 − return (
115 − <div
116 − className={cn("relative rounded-2xl border bg-bg-elevated shadow-sm transition-[border-color,box-shadow]", dragging ? "border-accent shadow-glow" : "border-border focus-within:border-border-strong focus-within:shadow-md")}
117 − onDragOver={(e) => {
118 − if (!canAttach) return;
119 − e.preventDefault();
120 − setDragging(true);
121 − }}
122 − onDragLeave={() => setDragging(false)}
123 − onDrop={(e) => {
124 − e.preventDefault();
125 − setDragging(false);
126 − if (canAttach && e.dataTransfer.files.length) void upload(e.dataTransfer.files);
127 − }}
128 − >
129 − {attachments.length || uploading ? (
130 − <div className="flex flex-wrap gap-1.5 px-3 pt-3">
131 − {attachments.map((a) => (
132 − <AttachmentChip key={a.id} a={a} onRemove={() => onAttachmentsChange(attachments.filter((x) => x.id !== a.id))} />
133 − ))}
134 − {uploading ? <div className="inline-flex h-9 items-center gap-2 rounded-lg border border-dashed border-border px-3 text-[12px] text-fg-muted">Uploading…</div> : null}
135 − </div>
136 − ) : null}
137 − <textarea
138 − ref={ref}
139 − value={text}
140 − onChange={(e) => setText(e.target.value)}
141 − onPaste={onPaste}
142 − onKeyDown={(e) => {
143 − if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && (enterToSend || e.metaKey || e.ctrlKey)) {
144 − e.preventDefault();
145 − submit();
146 − } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
147 − e.preventDefault();
148 − submit();
149 − } else if (e.key === "Escape" && busy) {
150 − onStop();
243 + const pasteFromClipboard = async () => {
244 + try {
245 + const nav = navigator as Navigator & { clipboard: Clipboard & { read?: () => Promise<ClipboardItem[]> } };
246 + if (nav.clipboard.read) {
247 + const items = await nav.clipboard.read();
248 + const files: File[] = [];
249 + let textFound = "";
250 + for (const item of items) {
251 + const imgType = item.types.find((t) => t.startsWith("image/"));
252 + if (imgType) {
253 + const blob = await item.getType(imgType);
254 + files.push(new File([blob], `pasted.${imgType.split("/")[1] ?? "png"}`, { type: imgType }));
255 + } else if (item.types.includes("text/plain")) {
256 + textFound += await (await item.getType("text/plain")).text();
151 257 }
258 + }
259 + if (files.length) await upload(files);
260 + if (textFound) insertAtCaret(textFound);
261 + if (!files.length && !textFound) toast.info("Clipboard is empty");
262 + return;
263 + }
264 + const t = await navigator.clipboard.readText();
265 + if (t) insertAtCaret(t);
266 + else toast.info("Clipboard is empty");
267 + } catch {
268 + toast.warning("Clipboard access denied", "Use ⌘V / Ctrl+V inside the message box instead.");
269 + }
270 + };
271 +
272 + // --- `+` menu -------------------------------------------------------------------------------------
273 + const items: (ComposerAction | "separator")[] = [
274 + ...(canAttach
275 + ? ([
276 + ...(canImages ? [{ key: "image", label: "Upload image", icon: <ImagePlus />, onSelect: () => openFilePicker("image") }] : []),
277 + { key: "document", label: "Upload document", icon: <FileText />, hint: canPdf ? "PDF, text, code" : "Text, code", onSelect: () => openFilePicker("document") },
278 + ...(canImages && coarse ? [{ key: "camera", label: "Camera", icon: <Camera />, onSelect: () => openFilePicker("camera") }] : []),
279 + { key: "paste", label: "Paste content", icon: <ClipboardPaste />, onSelect: () => void pasteFromClipboard() },
280 + { key: "library", label: "From library", icon: <Library />, onSelect: () => setLibraryOpen(true) },
281 + ] as ComposerAction[])
282 + : []),
283 + ...(extraActions?.length ? (canAttach ? (["separator", ...extraActions] as (ComposerAction | "separator")[]) : extraActions) : []),
284 + ];
285 + const activeCount = (extraActions ?? []).filter((i) => i !== "separator" && i.selected).length;
286 +
287 + const hasContent = Boolean(text.trim()) || attachments.length > 0;
288 + const showVoice = voice && speech.supported && !disabled;
289 +
290 + return (
291 + <div className="w-full">
292 + {topSlot}
293 + <div
294 + className={cn("relative rounded-[22px] border bg-bg-elevated shadow-sm transition-[border-color,box-shadow]", dragging ? "border-accent shadow-glow" : "border-border focus-within:border-border-strong focus-within:shadow-md", speech.listening && "border-accent/60")}
295 + onDragOver={(e) => {
296 + if (!canAttach) return;
297 + e.preventDefault();
298 + setDragging(true);
299 + }}
300 + onDragLeave={() => setDragging(false)}
301 + onDrop={(e) => {
302 + e.preventDefault();
303 + setDragging(false);
304 + if (canAttach && e.dataTransfer.files.length) void upload(e.dataTransfer.files);
152 305 }}
153 − placeholder={placeholder ?? (model ? `Message ${model.displayName}…` : "Choose a model to start…")}
154 − disabled={disabled}
155 − rows={1}
156 − className="block w-full resize-none bg-transparent px-4 pt-3 pb-2 text-[15px] leading-6 outline-none placeholder:text-fg-subtle disabled:opacity-60 scrollbar-thin"
157 − aria-label="Message"
158 − />
159 − <div className="flex items-center gap-1.5 px-2 pb-2">
160 − {leftSlot}
161 − <input ref={fileRef} type="file" multiple hidden accept={[canImages ? "image/png,image/jpeg,image/webp,image/gif" : "", model?.capabilities.files ? "application/pdf" : "", ".txt,.md,.csv,.json,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.kt,.swift,.rb,.php,.c,.cpp,.h,.cs,.sh,.yaml,.yml,.toml,.sql,.html,.css,.xml"].filter(Boolean).join(",")} onChange={(e) => e.target.files && upload(e.target.files)} />
162 − <Tooltip content={canImages ? "Attach image or file" : "Attach file (no vision on this model)"}>
163 − <Button variant="ghost" size="icon-sm" disabled={!canAttach || disabled} onClick={() => fileRef.current?.click()} aria-label="Attach">
164 − {canImages ? <ImagePlus /> : <Paperclip />}
165 − </Button>
166 − </Tooltip>
167 − <div className="flex-1" />
168 − {rightSlot}
169 − <span className="hidden items-center gap-1 text-[11px] text-fg-subtle sm:flex">
306 + >
307 + {attachments.length || uploading ? (
308 + <div className="flex flex-wrap gap-1.5 px-3 pt-3">
309 + {attachments.map((a) => (
310 + <AttachmentChip key={a.id} a={a} onRemove={() => onAttachmentsChange(attachments.filter((x) => x.id !== a.id))} />
311 + ))}
312 + {uploading ? <div className="inline-flex h-9 items-center gap-2 rounded-lg border border-dashed border-border px-3 text-[12px] text-fg-muted">Uploading…</div> : null}
313 + </div>
314 + ) : null}
315 +
316 + <div className="flex items-end gap-1 px-1.5 py-1.5">
317 + {/* + menu */}
318 + <input ref={fileRef} type="file" multiple hidden accept={fileAccept || undefined} onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />
319 + <input ref={cameraRef} type="file" hidden accept="image/*" capture="environment" onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />
320 + {leftSlot}
321 + <PlusMenu items={items} open={menuOpen} onOpenChange={setMenuOpen} disabled={disabled && !extraActions?.length} activeCount={activeCount} isMobile={isMobile} />
322 +
323 + {/* textarea */}
324 + <div className="relative min-w-0 flex-1">
325 + <textarea
326 + ref={taRef}
327 + value={text}
328 + onChange={(e) => setText(e.target.value)}
329 + onPaste={onPaste}
330 + onKeyDown={(e) => {
331 + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && (enterToSend || e.metaKey || e.ctrlKey)) {
332 + e.preventDefault();
333 + submit();
334 + } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
335 + e.preventDefault();
336 + submit();
337 + } else if (e.key === "Escape" && busy) {
338 + onStop();
339 + }
340 + }}
341 + placeholder={placeholder ?? (speech.listening ? "Listening…" : "Ask anything…")}
342 + disabled={disabled}
343 + rows={1}
344 + className={cn("block w-full resize-none bg-transparent px-2 pb-2.5 pt-2.5 text-[16px] leading-6 outline-none placeholder:text-fg-subtle disabled:opacity-60 scrollbar-thin sm:text-[15px]", multiline && "pr-8")}
345 + aria-label="Message"
346 + enterKeyHint={enterToSend ? "send" : "enter"}
347 + autoCapitalize="sentences"
348 + />
349 + {multiline ? (
350 + <button type="button" onClick={() => setCollapsed((c) => !c)} className="tap absolute right-0 top-1 rounded-md p-1 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label={collapsed ? "Expand message" : "Collapse message"}>
351 + {collapsed ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
352 + </button>
353 + ) : null}
354 + </div>
355 +
356 + {rightSlot}
357 + {/* voice */}
358 + {showVoice ? (
359 + <Tooltip content={speech.listening ? "Stop dictation" : "Dictate"}>
360 + <Button type="button" variant="ghost" size="icon" className={cn("tap size-9 shrink-0 rounded-full", speech.listening && "bg-accent-soft text-accent animate-pulse-soft")} onClick={toggleVoice} aria-label={speech.listening ? "Stop dictation" : "Start dictation"} aria-pressed={speech.listening}>
361 + {speech.listening ? <MicOff /> : <Mic />}
362 + </Button>
363 + </Tooltip>
364 + ) : null}
365 + {/* send / stop */}
366 + {busy ? (
367 + <Tooltip content="Stop generation (Esc)">
368 + <Button type="button" size="icon" variant="secondary" className="tap size-9 shrink-0 rounded-full" onClick={onStop} aria-label="Stop generation">
369 + <Square className="size-3.5 fill-current" />
370 + </Button>
371 + </Tooltip>
372 + ) : (
373 + <Button type="button" size="icon" variant={hasContent ? "accent" : "secondary"} className="tap size-9 shrink-0 rounded-full" onClick={submit} disabled={disabled || !hasContent || uploading > 0} aria-label="Send">
374 + <ArrowUp />
375 + </Button>
376 + )}
377 + </div>
378 + </div>
379 + {bottomSlot ?? (
380 + <p className="mt-1.5 hidden items-center justify-center gap-1 text-center text-[11px] text-fg-subtle sm:flex">
170 381 {enterToSend ? (
171 382 <>
172 383 <Kbd>↵</Kbd> send · <Kbd>⇧↵</Kbd> newline
@@ -176,20 +387,47 @@ export function Composer({ model, disabled, busy, enterToSend = true, placeholde
176 387 <Kbd>⌘↵</Kbd> send
177 388 </>
178 389 )}
179 − </span>
180 − {busy ? (
181 − <Tooltip content="Stop generation (Esc)">
182 − <Button size="icon" variant="secondary" onClick={onStop} aria-label="Stop generation">
183 − <Square className="size-3.5 fill-current" />
184 − </Button>
185 − </Tooltip>
186 − ) : (
187 − <Button size="icon" variant="primary" onClick={submit} disabled={disabled || (!text.trim() && attachments.length === 0) || uploading > 0} aria-label="Send">
188 − <ArrowUp />
189 − </Button>
190 − )}
191 − </div>
192 − {!canFiles ? null : null}
390 + </p>
391 + )}
392 +
393 + <FileLibraryPicker open={libraryOpen} onOpenChange={setLibraryOpen} projectId={projectId} onPick={(files: PickedAttachment[]) => onAttachmentsChange([...attachments, ...files].slice(0, 10))} />
193 394 </div>
194 395 );
396 +});
397 +
398 +function PlusMenu({ items, open, onOpenChange, disabled, activeCount, isMobile }: { items: (ComposerAction | "separator")[]; open: boolean; onOpenChange: (o: boolean) => void; disabled?: boolean; activeCount: number; isMobile: boolean }) {
399 + const trigger = (
400 + <Button type="button" variant="ghost" size="icon" className="tap relative size-9 shrink-0 rounded-full" disabled={disabled || items.length === 0} aria-label="Add attachment or option" aria-haspopup="menu" onClick={isMobile ? () => onOpenChange(true) : undefined}>
401 + <Plus className={cn("transition-transform", open && "rotate-45")} />
402 + {activeCount > 0 ? <span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-accent text-[10px] font-semibold text-accent-fg">{activeCount}</span> : null}
403 + </Button>
404 + );
405 + if (isMobile) {
406 + const sheetItems: (ActionSheetItem | "separator")[] = items.map((it) => (it === "separator" ? "separator" : { key: it.key, label: it.label, icon: it.icon, hint: it.hint, onSelect: it.onSelect, selected: it.selected, disabled: it.disabled, destructive: it.destructive }));
407 + return (
408 + <>
409 + {trigger}
410 + <ActionSheet open={open} onOpenChange={onOpenChange} items={sheetItems} />
411 + </>
412 + );
413 + }
414 + return (
415 + <DropdownMenu open={open} onOpenChange={onOpenChange}>
416 + <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
417 + <DropdownMenuContent align="start" side="top" sideOffset={8} className="min-w-[240px]">
418 + {items.map((it, i) =>
419 + it === "separator" ? (
420 + <DropdownMenuSeparator key={`sep-${i}`} />
421 + ) : (
422 + <DropdownMenuItem key={it.key} disabled={it.disabled} destructive={it.destructive} onSelect={() => setTimeout(it.onSelect, 10)} className="gap-2.5">
423 + <span className="text-fg-muted [&_svg]:size-4">{it.icon}</span>
424 + <span className="min-w-0 flex-1 truncate">{it.label}</span>
425 + {it.hint ? <span className="ml-2 text-[11px] text-fg-subtle">{it.hint}</span> : null}
426 + {it.selected ? <span className="ml-2 text-accent">✓</span> : null}
427 + </DropdownMenuItem>
428 + ),
429 + )}
430 + </DropdownMenuContent>
431 + </DropdownMenu>
432 + );
195 433 }
added src/components/chat/context-indicator.tsx +87 −0
@@ -0,0 +1,87 @@
1 +"use client";
2 +import * as React from "react";
3 +import { AlertTriangle, ArrowUpRight, FileText, Loader2, MessageSquarePlus } from "lucide-react";
4 +import type { PolyModel } from "@/lib/client/types";
5 +import type { ContextEstimate, CostEstimate } from "@/lib/client/tokens";
6 +import { formatEstimate } from "@/lib/client/tokens";
7 +import { Button } from "@/components/ui/button";
8 +import { Tooltip } from "@/components/ui/tooltip";
9 +import { cn, formatTokens } from "@/lib/utils";
10 +
11 +interface Props {
12 + estimate: ContextEstimate;
13 + cost: CostEstimate;
14 + model?: PolyModel;
15 + showCost?: boolean;
16 + onNewChat: () => void;
17 + onSummarize?: () => void;
18 + summarizing?: boolean;
19 + largerModel?: PolyModel | null;
20 + onSwitchLarger?: (key: string) => void;
21 + className?: string;
22 +}
23 +
24 +function fmtK(n: number): string {
25 + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
26 + if (n >= 1000) return `${Math.round(n / 1000)}K`;
27 + return String(n);
28 +}
29 +
30 +/**
31 + * Compact "43K / 200K" context meter + cost estimate. Turns into an action banner at 80 % (warn),
32 + * 95 % (critical) and when the estimate exceeds the window.
33 + */
34 +export function ContextIndicator({ estimate, cost, showCost = true, onNewChat, onSummarize, summarizing, largerModel, onSwitchLarger, className }: Props) {
35 + const ratio = estimate.ratio ?? 0;
36 + const level = estimate.level;
37 + const bar = level === "over" || level === "critical" ? "bg-danger" : level === "warn" ? "bg-warning" : "bg-accent";
38 + const pct = Math.min(100, Math.round(ratio * 100));
39 + const label = estimate.contextTokens ? `${fmtK(estimate.total)} / ${fmtK(estimate.contextTokens)}` : `~${fmtK(estimate.total)} tokens`;
40 + const costLabel = showCost ? formatEstimate(estimate.total, cost.usd) : `~${fmtK(estimate.total)} tokens`;
41 +
42 + return (
43 + <div className={cn("space-y-1.5", className)}>
44 + <div className="flex min-w-0 items-center gap-2 text-[11px] tabular-nums text-fg-subtle">
45 + <Tooltip content={estimate.contextTokens ? `Estimated context: ${formatTokens(estimate.history)} history + ${formatTokens(estimate.draft)} draft of ${formatTokens(estimate.contextTokens)} (${pct}%)` : "Estimated tokens in this request (no known context limit)"}>
46 + <span className="inline-flex shrink-0 items-center gap-1.5">
47 + <span className={cn(level !== "ok" && (level === "warn" ? "text-warning" : "text-danger"))}>{label}</span>
48 + {estimate.contextTokens ? (
49 + <span className="relative h-1 w-14 overflow-hidden rounded-full bg-border sm:w-20" aria-hidden>
50 + <span className={cn("absolute inset-y-0 left-0 rounded-full transition-[width] duration-300", bar)} style={{ width: `${Math.max(2, pct)}%` }} />
51 + </span>
52 + ) : null}
53 + </span>
54 + </Tooltip>
55 + <span className="hidden text-border-strong sm:inline">·</span>
56 + <Tooltip content={cost.usd === null ? "No public pricing for this model" : `Input ≈ $${(cost.inputUsd ?? 0).toFixed(4)} · output (~${cost.outputTokens} tokens) ≈ $${(cost.outputUsd ?? 0).toFixed(4)} — provider list prices`}>
57 + <span className="hidden truncate sm:inline">{costLabel}</span>
58 + </Tooltip>
59 + {cost.usd !== null && showCost ? <span className="truncate sm:hidden">≈ {cost.usd < 0.01 ? `$${cost.usd.toFixed(4)}` : `$${cost.usd.toFixed(2)}`}</span> : null}
60 + </div>
61 +
62 + {level !== "ok" ? (
63 + <div className={cn("flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl px-3 py-2 text-[12.5px]", level === "warn" ? "bg-warning-soft text-warning" : "bg-danger-soft text-danger")} role="status">
64 + <span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
65 + <AlertTriangle className="size-3.5 shrink-0" />
66 + <span className="truncate">{level === "over" ? "This request exceeds the model's context window." : level === "critical" ? `Context almost full (${pct}%). Answers may be cut off.` : `Context ${pct}% full — consider trimming.`}</span>
67 + </span>
68 + <div className="flex flex-wrap items-center gap-1.5">
69 + <Button size="xs" variant="outline" onClick={onNewChat} className="bg-bg-elevated">
70 + <MessageSquarePlus /> New chat
71 + </Button>
72 + {onSummarize ? (
73 + <Button size="xs" variant="outline" onClick={onSummarize} disabled={summarizing} className="bg-bg-elevated">
74 + {summarizing ? <Loader2 className="animate-spin" /> : <FileText />} {summarizing ? "Summarizing…" : "Summarize context"}
75 + </Button>
76 + ) : null}
77 + {largerModel && onSwitchLarger ? (
78 + <Button size="xs" variant="outline" onClick={() => onSwitchLarger(largerModel.key)} className="bg-bg-elevated">
79 + <ArrowUpRight /> Switch to {largerModel.displayName} ({fmtK(largerModel.limits?.contextTokens ?? 0)})
80 + </Button>
81 + ) : null}
82 + </div>
83 + </div>
84 + ) : null}
85 + </div>
86 + );
87 +}
added src/components/chat/cost-confirm.tsx +67 −0
@@ -0,0 +1,67 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Coins } from "lucide-react";
4 +import type { PolyModel } from "@/lib/client/types";
5 +import type { RouteCandidate } from "@/lib/client/router";
6 +import type { CostEstimate } from "@/lib/client/tokens";
7 +import { ResponsiveDialog } from "@/components/ui/sheet";
8 +import { Button } from "@/components/ui/button";
9 +import { ProviderIcon } from "@/components/brand/provider-icon";
10 +import { formatTokens } from "@/lib/utils";
11 +
12 +function usd(n: number | null): string {
13 + if (n === null) return "—";
14 + return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
15 +}
16 +
17 +/** Shown before sending when the estimate exceeds `COST_CONFIRM_THRESHOLD_USD`; offers cheaper models with one-tap switching. */
18 +export function CostConfirm({ open, onOpenChange, model, estimate, alternatives, onSend, onSwitch }: { open: boolean; onOpenChange: (o: boolean) => void; model: PolyModel | null | undefined; estimate: CostEstimate | null; alternatives: RouteCandidate[]; onSend: () => void; onSwitch: (modelKey: string) => void }) {
19 + return (
20 + <ResponsiveDialog
21 + open={open}
22 + onOpenChange={onOpenChange}
23 + title={
24 + <span className="inline-flex items-center gap-2">
25 + <Coins className="size-4 text-warning" /> This request may cost ≈ {usd(estimate?.usd ?? null)}
26 + </span>
27 + }
28 + description={model && estimate ? `${model.displayName} · ~${formatTokens(estimate.inputTokens)} input tokens + ~${formatTokens(estimate.outputTokens)} expected output, at provider list prices.` : undefined}
29 + size="sm"
30 + footer={
31 + <div className="flex gap-2 sm:justify-end">
32 + <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => onOpenChange(false)}>
33 + Cancel
34 + </Button>
35 + <Button variant="primary" className="flex-1 sm:flex-none" onClick={onSend}>
36 + Send anyway
37 + </Button>
38 + </div>
39 + }
40 + >
41 + <div className="space-y-3 pt-1">
42 + {alternatives.length ? (
43 + <div>
44 + <p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Cheaper alternatives</p>
45 + <ul className="divide-y divide-hairline overflow-hidden rounded-xl border border-border">
46 + {alternatives.map((a) => (
47 + <li key={a.model.key}>
48 + <button type="button" onClick={() => onSwitch(a.model.key)} className="flex min-h-[48px] w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-bg-subtle">
49 + <ProviderIcon provider={a.model.provider} size={14} />
50 + <span className="min-w-0 flex-1">
51 + <span className="block truncate text-[13.5px] font-medium">{a.model.displayName}</span>
52 + <span className="block truncate text-[12px] text-fg-muted">{a.reasons.length ? a.reasons.join(" · ") : "Fits this prompt"}</span>
53 + </span>
54 + <span className="shrink-0 text-[12.5px] font-medium tabular-nums text-success">≈ {usd(a.estimatedUsd)}</span>
55 + </button>
56 + </li>
57 + ))}
58 + </ul>
59 + <p className="mt-1.5 text-[11.5px] text-fg-subtle">Tap a model to switch and send immediately.</p>
60 + </div>
61 + ) : (
62 + <p className="text-[13px] text-fg-muted">No cheaper connected model can take this prompt (context, vision or file requirements).</p>
63 + )}
64 + </div>
65 + </ResponsiveDialog>
66 + );
67 +}
modified src/components/chat/empty-state.tsx +57 −21
@@ -1,30 +1,63 @@
1 1 "use client";
2 2 import * as React from "react";
3 3 import Link from "next/link";
4 −import { KeyRound, Lightbulb, Code2, PenLine, BarChart3 } from "lucide-react";
4 +import { KeyRound, Code2, PenLine, Search, FileUp, Columns3, EyeOff } from "lucide-react";
5 5 import type { PolyModel } from "@/lib/client/types";
6 6 import { useApp } from "@/components/app/store";
7 7 import { LogoMark } from "@/components/brand/logo";
8 8 import { Button } from "@/components/ui/button";
9 9 import { CapabilityBadges } from "./model-badges";
10 10 import { Onboarding } from "@/components/app/onboarding";
11 +import { cn } from "@/lib/utils";
11 12
12 −const SUGGESTIONS = [
13 − { icon: <Code2 className="size-4" />, title: "Review this code", text: "Review the following code for bugs, edge cases and readability. Suggest concrete fixes.\n\n```\n\n```" },
14 − { icon: <Lightbulb className="size-4" />, title: "Explain a concept", text: "Explain how transformer attention works to a senior backend engineer, with a short worked example." },
15 − { icon: <PenLine className="size-4" />, title: "Draft an email", text: "Draft a concise, friendly email to a client explaining a two-day delay and proposing a new timeline." },
16 − { icon: <BarChart3 className="size-4" />, title: "Analyze data", text: "Here is a CSV. Summarize the key trends, outliers and three actionable insights.\n\n" },
13 +interface QuickAction {
14 + key: string;
15 + icon: React.ReactNode;
16 + title: string;
17 + hint: string;
18 + /** Text inserted into the composer (Write / Code / Research). */
19 + text?: string;
20 +}
21 +
22 +const QUICK: QuickAction[] = [
23 + { key: "write", icon: <PenLine className="size-4" />, title: "Write", hint: "Emails, posts, docs", text: "Help me write a concise, friendly " },
24 + { key: "code", icon: <Code2 className="size-4" />, title: "Code", hint: "Review, debug, generate", text: "Review the following code for bugs, edge cases and readability, and suggest concrete fixes.\n\n```\n\n```" },
25 + { key: "research", icon: <Search className="size-4" />, title: "Research", hint: "Sources, comparisons", text: "Research the current state of " },
26 + { key: "file", icon: <FileUp className="size-4" />, title: "Analyze a file", hint: "PDF, CSV, image, code" },
27 + { key: "compare", icon: <Columns3 className="size-4" />, title: "Compare models", hint: "Same prompt, 2–4 models" },
17 28 ];
18 29
19 −export function ChatEmptyState({ model, onPick }: { model?: PolyModel; onPick: (text: string) => void }) {
30 +export function ChatEmptyState({ model, onPick, onAnalyzeFile, onCompare, temporary, projectName }: { model?: PolyModel; onPick: (text: string) => void; onAnalyzeFile?: () => void; onCompare?: () => void; temporary?: boolean; projectName?: string | null }) {
20 31 const { connectedProviders, loadingModels, user, connections } = useApp();
21 32 const noProviders = !loadingModels && connectedProviders.size === 0;
22 33 return (
23 − <div className="mx-auto flex h-full w-full max-w-3xl flex-col items-center justify-center px-4 py-10 text-center">
34 + <div className="mx-auto flex h-full w-full max-w-3xl flex-col items-center justify-center px-4 py-8 text-center sm:py-10">
24 35 {!user.onboardingCompletedAt && connections.length === 0 ? <Onboarding /> : null}
25 − <LogoMark size={44} className="mb-4 shadow-md rounded-xl" />
26 − <h1 className="text-2xl font-semibold tracking-tight">What are we working on?</h1>
27 − <p className="mt-1.5 max-w-md text-[14px] text-fg-muted">{model ? <>Chatting with <span className="font-medium text-fg">{model.displayName}</span>. Switch anytime with ⌘/.</> : "One interface. Every model. Bring your own keys."}</p>
36 + <LogoMark size={44} className="mb-4 rounded-xl shadow-md" />
37 + <h1 className="text-balance text-[22px] font-semibold tracking-tight sm:text-2xl">What do you want to work on?</h1>
38 + <p className="mt-1.5 max-w-md text-balance text-[14px] text-fg-muted">
39 + {temporary ? (
40 + <span className="inline-flex items-center gap-1.5">
41 + <EyeOff className="size-3.5" /> Temporary chat — not stored in history.
42 + </span>
43 + ) : projectName ? (
44 + <>
45 + In project <span className="font-medium text-fg">{projectName}</span>
46 + {model ? (
47 + <>
48 + {" "}
49 + · <span className="font-medium text-fg">{model.displayName}</span>
50 + </>
51 + ) : null}
52 + </>
53 + ) : model ? (
54 + <>
55 + Chatting with <span className="font-medium text-fg">{model.displayName}</span>. <span className="hidden sm:inline">Switch anytime with ⌘/.</span>
56 + </>
57 + ) : (
58 + "One interface. Every model. Bring your own keys."
59 + )}
60 + </p>
28 61 {model ? <CapabilityBadges model={model} className="mt-3 justify-center" /> : null}
29 62 {noProviders ? (
30 63 <div className="mt-6 flex flex-col items-center gap-3 rounded-xl border border-border bg-bg-elevated p-5">
@@ -36,16 +69,19 @@ export function ChatEmptyState({ model, onPick }: { model?: PolyModel; onPick: (
36 69 </Button>
37 70 </div>
38 71 ) : (
39 − <div className="mt-8 grid w-full gap-2 sm:grid-cols-2">
40 − {SUGGESTIONS.map((s) => (
41 − <button key={s.title} onClick={() => onPick(s.text)} className="flex items-start gap-3 rounded-xl border border-border bg-bg-elevated p-3.5 text-left transition-colors hover:border-border-strong hover:bg-bg-subtle">
42 − <span className="mt-0.5 text-accent">{s.icon}</span>
43 − <span>
44 − <span className="block text-[13.5px] font-medium">{s.title}</span>
45 − <span className="mt-0.5 block text-[12.5px] text-fg-muted line-clamp-2">{s.text.split("\n")[0]}</span>
46 − </span>
47 − </button>
48 − ))}
72 + <div className="mt-7 grid w-full grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
73 + {QUICK.map((q) => {
74 + const onClick = q.key === "file" ? onAnalyzeFile : q.key === "compare" ? onCompare : () => onPick(q.text ?? "");
75 + return (
76 + <button key={q.key} type="button" onClick={onClick} disabled={!onClick} className={cn("flex min-h-[84px] flex-col items-start gap-2 rounded-xl border border-border bg-bg-elevated p-3 text-left transition-colors hover:border-border-strong hover:bg-bg-subtle disabled:opacity-50", q.key === "compare" && "col-span-2 sm:col-span-1")}>
77 + <span className="flex size-8 items-center justify-center rounded-lg bg-accent-soft text-accent">{q.icon}</span>
78 + <span className="min-w-0">
79 + <span className="block text-[13.5px] font-medium">{q.title}</span>
80 + <span className="block text-[12px] text-fg-muted">{q.hint}</span>
81 + </span>
82 + </button>
83 + );
84 + })}
49 85 </div>
50 86 )}
51 87 </div>
added src/components/chat/message-error.tsx +98 −0
@@ -0,0 +1,98 @@
1 +"use client";
2 +import * as React from "react";
3 +import Link from "next/link";
4 +import { AlertCircle, Info, KeyRound, RefreshCw, Shuffle } from "lucide-react";
5 +import { humanizeChatError, secondsLeft, type ChatErrorLike } from "@/lib/chat/humanize-error";
6 +import { ResponsiveDialog } from "@/components/ui/sheet";
7 +import { Button } from "@/components/ui/button";
8 +import { CopyButton } from "@/components/ui/misc";
9 +import { PROVIDERS } from "@/lib/client/providers";
10 +
11 +interface Props {
12 + error: ChatErrorLike;
13 + /** Provider id (message.provider) for the copy. */
14 + provider?: string | null;
15 + requestId?: string | null;
16 + /** When the error happened (ms) — drives the retry countdown. */
17 + since?: number;
18 + onRetry?: () => void;
19 + onSwitchModel?: () => void;
20 + compact?: boolean;
21 +}
22 +
23 +/**
24 + * Humanized provider error with Retry (live countdown from `retryAfterMs`), Switch model and
25 + * View details (code, provider code, HTTP status, request id).
26 + */
27 +export function MessageError({ error, provider, requestId, since, onRetry, onSwitchModel, compact }: Props) {
28 + const providerName = provider ? PROVIDERS[provider as keyof typeof PROVIDERS]?.name ?? provider : undefined;
29 + const h = React.useMemo(() => humanizeChatError(error, { providerName, requestId }), [error, providerName, requestId]);
30 + const [detailsOpen, setDetailsOpen] = React.useState(false);
31 + const [now, setNow] = React.useState(() => since ?? 0);
32 + const start = since ?? 0;
33 + const wait = h.retryAfterMs && since ? secondsLeft(h.retryAfterMs, start, now) : null;
34 +
35 + React.useEffect(() => {
36 + if (!h.retryAfterMs || !since) return;
37 + const t = setInterval(() => setNow(Date.now()), 500);
38 + return () => clearInterval(t);
39 + }, [h.retryAfterMs, since]);
40 +
41 + return (
42 + <div className="rounded-xl border border-danger/25 bg-danger-soft/70 px-3 py-2.5 text-[13px] text-fg" role="alert">
43 + <div className="flex items-start gap-2">
44 + <AlertCircle className="mt-0.5 size-4 shrink-0 text-danger" />
45 + <div className="min-w-0 flex-1">
46 + <p className="font-medium text-danger">{h.title}</p>
47 + <p className={compact ? "text-[12.5px] text-fg-muted" : "mt-0.5 text-[12.5px] text-fg-muted"}>{h.description}</p>
48 + </div>
49 + </div>
50 + <div className="mt-2 flex flex-wrap items-center gap-1.5 pl-6">
51 + {onRetry && h.canRetry ? (
52 + <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={onRetry} disabled={wait !== null}>
53 + <RefreshCw /> {wait !== null ? `Retry in ${wait}s` : "Retry"}
54 + </Button>
55 + ) : null}
56 + {onSwitchModel && h.suggestSwitch ? (
57 + <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={onSwitchModel}>
58 + <Shuffle /> Switch model
59 + </Button>
60 + ) : null}
61 + {h.suggestProviders ? (
62 + <Button asChild size="xs" variant="outline" className="bg-bg-elevated">
63 + <Link href="/app/settings/providers">
64 + <KeyRound /> Providers
65 + </Link>
66 + </Button>
67 + ) : null}
68 + <Button size="xs" variant="ghost" onClick={() => setDetailsOpen(true)}>
69 + <Info /> View details
70 + </Button>
71 + </div>
72 +
73 + <ResponsiveDialog open={detailsOpen} onOpenChange={setDetailsOpen} title="Error details" description="Safe diagnostics — no keys or prompts are included." size="sm">
74 + <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 pt-1 text-[13px]">
75 + <Row k="Code" v={h.details.code} mono />
76 + <Row k="Provider" v={h.details.provider ? PROVIDERS[h.details.provider as keyof typeof PROVIDERS]?.name ?? h.details.provider : "—"} />
77 + <Row k="Provider code" v={h.details.providerCode ?? "—"} mono />
78 + <Row k="HTTP status" v={h.details.status !== null ? String(h.details.status) : "—"} mono />
79 + <Row k="Request id" v={h.details.requestId ?? "—"} mono />
80 + {h.retryAfterMs ? <Row k="Retry after" v={`${Math.ceil(h.retryAfterMs / 1000)} s`} /> : null}
81 + {h.details.raw ? <Row k="Provider message" v={h.details.raw} /> : null}
82 + </dl>
83 + <div className="mt-4 flex justify-end">
84 + <CopyButton size="sm" label="Copy details" value={JSON.stringify({ ...h.details, retryAfterMs: h.retryAfterMs }, null, 2)} />
85 + </div>
86 + </ResponsiveDialog>
87 + </div>
88 + );
89 +}
90 +
91 +function Row({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
92 + return (
93 + <>
94 + <dt className="text-fg-subtle">{k}</dt>
95 + <dd className={mono ? "break-all font-mono text-[12px]" : "break-words"}>{v}</dd>
96 + </>
97 + );
98 +}
modified src/components/chat/message.tsx +186 −108
@@ -1,13 +1,18 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, Copy, FileText, GitBranch, Globe, Loader2, Pencil, RefreshCw, StepForward, Trash2, Wrench, X, ExternalLink } from "lucide-react";
4 −import type { PublicMessage, StoredPart, PolyModel } from "@/lib/client/types";
3 +import { AlertTriangle, BookmarkPlus, Brain, Check, ChevronDown, ChevronRight, Columns3, Copy, ExternalLink, FileText, FileDown, GitBranch, Globe, Loader2, MoreHorizontal, Pencil, Quote, RefreshCw, Shuffle, StepForward, Trash2, Wrench, X } from "lucide-react";
4 +import type { PublicMessage, StoredPart, PolyModel, StoredMessageError } from "@/lib/client/types";
5 5 import { Markdown } from "@/components/markdown/markdown";
6 6 import { ProviderIcon } from "@/components/brand/provider-icon";
7 7 import { Button } from "@/components/ui/button";
8 8 import { Textarea } from "@/components/ui/input";
9 9 import { Tooltip } from "@/components/ui/tooltip";
10 10 import { Badge } from "@/components/ui/badge";
11 +import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";
12 +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
13 +import { useIsMobile, useLongPress } from "@/lib/client/hooks";
14 +import { deprecationNotice } from "@/lib/chat/deprecation";
15 +import { MessageError } from "./message-error";
11 16 import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";
12 17 import { PROVIDERS } from "@/lib/client/providers";
13 18
@@ -24,16 +29,27 @@ export interface LiveState {
24 29 export interface MessageActions {
25 30 onCopy?: (text: string) => void;
26 31 onEdit?: (m: PublicMessage, text: string) => void;
32 + /** Retry with the same model. */
27 33 onRegenerate?: (m: PublicMessage) => void;
34 + /** Opens the model picker, then regenerates with the chosen model. */
35 + onRegenerateWith?: (m: PublicMessage) => void;
28 36 onRetry?: (m: PublicMessage) => void;
29 37 onContinue?: (m: PublicMessage) => void;
30 38 onBranch?: (m: PublicMessage) => void;
31 39 onDelete?: (m: PublicMessage) => void;
40 + onCompare?: (m: PublicMessage) => void;
41 + onQuote?: (m: PublicMessage) => void;
42 + onSaveAsPrompt?: (m: PublicMessage) => void;
43 + onExport?: (m: PublicMessage) => void;
44 + /** "Switch to <replacement>" from the deprecated-model notice. */
45 + onSwitchModel?: (key: string) => void;
32 46 }
33 47
34 48 interface Props {
35 49 message: PublicMessage;
36 50 model?: PolyModel;
51 + /** Suggested replacement when `model` is deprecated / retiring (computed by the parent). */
52 + replacement?: PolyModel | null;
37 53 live?: LiveState | null;
38 54 isLast?: boolean;
39 55 wrapCode?: boolean;
@@ -41,31 +57,116 @@ interface Props {
41 57 showCosts?: boolean;
42 58 actions?: MessageActions;
43 59 busy?: boolean;
60 + /** Request id of the turn that produced this message (for the error details sheet). */
61 + requestId?: string | null;
62 + /** Epoch ms when the error was received (drives the retry countdown). */
63 + errorAt?: number;
44 64 }
45 65
46 −export const MessageItem = React.memo(function MessageItem({ message: m, model, live, isLast, wrapCode, showReasoning = true, showCosts = true, actions, busy }: Props) {
66 +type Item = { key: string; label: string; icon: React.ReactNode; onSelect: () => void; destructive?: boolean; hidden?: boolean };
67 +
68 +export const MessageItem = React.memo(function MessageItem({ message: m, model, replacement, live, isLast, wrapCode, showReasoning = true, showCosts = true, actions, busy, requestId, errorAt }: Props) {
47 69 const isUser = m.role === "user";
48 70 const streaming = m.status === "streaming" && Boolean(live);
71 + const isMobile = useIsMobile();
49 72 const [editing, setEditing] = React.useState(false);
50 73 const [draft, setDraft] = React.useState(m.content);
51 74 const [copied, setCopied] = React.useState(false);
75 + const [sheetOpen, setSheetOpen] = React.useState(false);
76 + const [detailsOpen, setDetailsOpen] = React.useState(false);
77 + const [now] = React.useState(() => Date.now());
52 78
53 79 const text = streaming ? live!.text : m.content;
54 − const reasoningParts = (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "reasoning" }> => p.type === "reasoning");
80 + const parts = m.parts as StoredPart[];
81 + const reasoningParts = parts.filter((p): p is Extract<StoredPart, { type: "reasoning" }> => p.type === "reasoning");
55 82 const reasoning = streaming ? live!.reasoning : reasoningParts.map((p) => p.text).join("\n\n");
56 − const attachments = (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "attachment" }> => p.type === "attachment");
57 − const toolCalls = streaming ? live!.tools : (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "tool-call" }> => p.type === "tool-call").map((p) => ({ id: p.id, name: p.name, args: p.argumentsText ?? JSON.stringify(p.arguments), result: p.result, isError: p.isError, durationMs: p.durationMs }));
58 − const citations = streaming ? live!.citations : (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "citation" }> => p.type === "citation");
59 − const serverTools = streaming ? live!.serverTools : (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "server-tool" }> => p.type === "server-tool");
60 − const refusal = (m.parts as StoredPart[]).find((p): p is Extract<StoredPart, { type: "refusal" }> => p.type === "refusal");
83 + const attachments = parts.filter((p): p is Extract<StoredPart, { type: "attachment" }> => p.type === "attachment");
84 + const toolCalls = streaming ? live!.tools : parts.filter((p): p is Extract<StoredPart, { type: "tool-call" }> => p.type === "tool-call").map((p) => ({ id: p.id, name: p.name, args: p.argumentsText ?? JSON.stringify(p.arguments), result: p.result, isError: p.isError, durationMs: p.durationMs }));
85 + const citations = streaming ? live!.citations : parts.filter((p): p is Extract<StoredPart, { type: "citation" }> => p.type === "citation");
86 + const serverTools = streaming ? live!.serverTools : parts.filter((p): p is Extract<StoredPart, { type: "server-tool" }> => p.type === "server-tool");
87 + const refusal = parts.find((p): p is Extract<StoredPart, { type: "refusal" }> => p.type === "refusal");
61 88 const usage = m.usage as { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } | null;
89 + const settings = (m as { settings?: Record<string, unknown> | null }).settings ?? null;
62 90 const tps = m.latencyMs && usage?.outputTokens ? Math.round((usage.outputTokens / Math.max(1, m.latencyMs - (m.ttftMs ?? 0))) * 1000) : null;
91 + const notice = deprecationNotice(model, now);
63 92
64 − const copy = async () => {
93 + const copy = React.useCallback(async () => {
65 94 await navigator.clipboard.writeText(text).catch(() => {});
95 + actions?.onCopy?.(text);
66 96 setCopied(true);
67 97 setTimeout(() => setCopied(false), 1400);
68 − };
98 + }, [text, actions]);
99 +
100 + // --- action list (shared by the hover toolbar, the "more" menu and the long-press sheet) ----------
101 + const items: Item[] = isUser
102 + ? [
103 + { key: "copy", label: copied ? "Copied" : "Copy", icon: copied ? <Check className="text-success" /> : <Copy />, onSelect: copy },
104 + { key: "edit", label: "Edit & resend", icon: <Pencil />, onSelect: () => setEditing(true), hidden: !actions?.onEdit || busy },
105 + { key: "quote", label: "Quote", icon: <Quote />, onSelect: () => actions?.onQuote?.(m), hidden: !actions?.onQuote },
106 + { key: "branch", label: "Branch from here", icon: <GitBranch />, onSelect: () => actions?.onBranch?.(m), hidden: !actions?.onBranch },
107 + { key: "prompt", label: "Save as prompt", icon: <BookmarkPlus />, onSelect: () => actions?.onSaveAsPrompt?.(m), hidden: !actions?.onSaveAsPrompt },
108 + { key: "export", label: "Export message", icon: <FileDown />, onSelect: () => actions?.onExport?.(m), hidden: !actions?.onExport },
109 + { key: "delete", label: "Delete", icon: <Trash2 />, onSelect: () => actions?.onDelete?.(m), destructive: true, hidden: !actions?.onDelete || busy },
110 + ]
111 + : [
112 + { key: "copy", label: copied ? "Copied" : "Copy", icon: copied ? <Check className="text-success" /> : <Copy />, onSelect: copy },
113 + { key: "retry", label: "Retry", icon: <RefreshCw />, onSelect: () => actions?.onRegenerate?.(m), hidden: !actions?.onRegenerate || busy },
114 + { key: "retry-with", label: "Retry with another model", icon: <Shuffle />, onSelect: () => actions?.onRegenerateWith?.(m), hidden: !actions?.onRegenerateWith || busy },
115 + { key: "continue", label: "Continue", icon: <StepForward />, onSelect: () => actions?.onContinue?.(m), hidden: !actions?.onContinue || !isLast || busy || !(m.finishReason === "length" || m.status === "stopped") },
116 + { key: "compare", label: "Compare this response", icon: <Columns3 />, onSelect: () => actions?.onCompare?.(m), hidden: !actions?.onCompare || busy },
117 + { key: "branch", label: "Branch from here", icon: <GitBranch />, onSelect: () => actions?.onBranch?.(m), hidden: !actions?.onBranch },
118 + { key: "quote", label: "Quote", icon: <Quote />, onSelect: () => actions?.onQuote?.(m), hidden: !actions?.onQuote },
119 + { key: "prompt", label: "Save as prompt", icon: <BookmarkPlus />, onSelect: () => actions?.onSaveAsPrompt?.(m), hidden: !actions?.onSaveAsPrompt },
120 + { key: "export", label: "Export message", icon: <FileDown />, onSelect: () => actions?.onExport?.(m), hidden: !actions?.onExport },
121 + { key: "delete", label: "Delete", icon: <Trash2 />, onSelect: () => actions?.onDelete?.(m), destructive: true, hidden: !actions?.onDelete || busy },
122 + ];
123 + const visible = items.filter((i) => !i.hidden);
124 + const primaryKeys = isUser ? ["copy", "edit", "branch"] : ["copy", "retry", "continue", "compare", "branch"];
125 + const primary = visible.filter((i) => primaryKeys.includes(i.key));
126 + const more = visible.filter((i) => !primaryKeys.includes(i.key));
127 +
128 + const longPress = useLongPress({ onLongPress: () => setSheetOpen(true), disabled: !isMobile || streaming || editing || visible.length === 0 });
129 +
130 + const toolbar = !streaming ? (
131 + <div className={cn("flex items-center gap-0.5", isMobile ? "" : "hover-reveal")}>
132 + {primary.map((i) => (
133 + <IconBtn key={i.key} label={i.label} onClick={i.onSelect}>
134 + {i.icon}
135 + </IconBtn>
136 + ))}
137 + {more.length ? (
138 + isMobile ? (
139 + <IconBtn label="More actions" onClick={() => setSheetOpen(true)}>
140 + <MoreHorizontal />
141 + </IconBtn>
142 + ) : (
143 + <DropdownMenu>
144 + <DropdownMenuTrigger asChild>
145 + <button className="rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label="More actions">
146 + <MoreHorizontal />
147 + </button>
148 + </DropdownMenuTrigger>
149 + <DropdownMenuContent align={isUser ? "end" : "start"} className="min-w-[220px]">
150 + {more.map((i, idx) => (
151 + <React.Fragment key={i.key}>
152 + {i.destructive && idx > 0 ? <DropdownMenuSeparator /> : null}
153 + <DropdownMenuItem destructive={i.destructive} onSelect={() => setTimeout(i.onSelect, 10)}>
154 + {i.icon} {i.label}
155 + </DropdownMenuItem>
156 + </React.Fragment>
157 + ))}
158 + </DropdownMenuContent>
159 + </DropdownMenu>
160 + )
161 + ) : null}
162 + </div>
163 + ) : null;
164 +
165 + const sheet = isMobile ? (
166 + <ActionSheet open={sheetOpen} onOpenChange={setSheetOpen} items={toSheetItems(visible)} title={isUser ? "Your message" : model?.displayName ?? "Response"}>
167 + <p className="line-clamp-3 rounded-xl bg-bg-subtle px-3 py-2 text-[13px] text-fg-muted">{text || "(no text)"}</p>
168 + </ActionSheet>
169 + ) : null;
69 170
70 171 if (isUser) {
71 172 return (
@@ -79,7 +180,7 @@ export const MessageItem = React.memo(function MessageItem({ message: m, model,
79 180 </div>
80 181 ) : null}
81 182 {editing ? (
82 − <div className="w-full min-w-[280px] space-y-2 rounded-2xl border border-border bg-bg-elevated p-2">
183 + <div className="w-full min-w-[min(280px,80vw)] space-y-2 rounded-2xl border border-border bg-bg-elevated p-2">
83 184 <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[80px] border-0 shadow-none focus:ring-0" autoFocus />
84 185 <div className="flex justify-end gap-2">
85 186 <Button variant="ghost" size="xs" onClick={() => setEditing(false)}>
@@ -97,30 +198,16 @@ export const MessageItem = React.memo(function MessageItem({ message: m, model,
97 198 </div>
98 199 </div>
99 200 ) : (
100 − <div className="whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-relaxed">{m.content}</div>
201 + <div {...longPress} className="select-text whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-relaxed [-webkit-touch-callout:none]">
202 + {m.content}
203 + </div>
101 204 )}
102 − <div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
103 − <IconBtn label={copied ? "Copied" : "Copy"} onClick={copy}>
104 − {copied ? <Check className="text-success" /> : <Copy />}
105 − </IconBtn>
106 − {actions?.onEdit && !busy ? (
107 − <IconBtn label="Edit & resend" onClick={() => setEditing(true)}>
108 − <Pencil />
109 − </IconBtn>
110 − ) : null}
111 − {actions?.onBranch ? (
112 − <IconBtn label="Branch from here" onClick={() => actions.onBranch?.(m)}>
113 − <GitBranch />
114 − </IconBtn>
115 − ) : null}
116 − {actions?.onDelete && !busy ? (
117 − <IconBtn label="Delete" onClick={() => actions.onDelete?.(m)}>
118 − <Trash2 />
119 − </IconBtn>
120 − ) : null}
121 − <span className="ml-1 text-[11px] text-fg-subtle">{new Date(m.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
205 + <div className="flex items-center gap-0.5 text-[11px] text-fg-subtle">
206 + {toolbar}
207 + <span className="ml-1">{new Date(m.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
122 208 </div>
123 209 </div>
210 + {sheet}
124 211 </div>
125 212 );
126 213 }
@@ -128,6 +215,7 @@ export const MessageItem = React.memo(function MessageItem({ message: m, model,
128 215 const provider = m.provider ?? model?.provider;
129 216 const modelName = model?.displayName ?? m.modelKey?.split("/").slice(1).join("/") ?? "model";
130 217 const waiting = streaming && !live!.text && !live!.reasoning && live!.tools.length === 0 && live!.serverTools.length === 0;
218 + const err = m.error as StoredMessageError | null;
131 219
132 220 return (
133 221 <div id={m.id} className="group flex w-full gap-3 px-3 sm:px-0">
@@ -142,8 +230,21 @@ export const MessageItem = React.memo(function MessageItem({ message: m, model,
142 230 {m.status === "stopped" ? <Badge variant="warning">Stopped</Badge> : null}
143 231 {m.status === "error" ? <Badge variant="danger">Failed</Badge> : null}
144 232 {m.finishReason === "length" ? <Badge variant="warning">Cut off (max tokens)</Badge> : null}
233 + {notice ? <Badge variant={notice.kind === "deprecated" ? "danger" : "warning"}>{notice.kind === "deprecated" ? "Deprecated" : `Retires ${notice.shutdownDate}`}</Badge> : null}
145 234 </div>
146 235
236 + {notice && isLast && !streaming && replacement && actions?.onSwitchModel ? (
237 + <div className="flex flex-wrap items-center gap-2 rounded-xl bg-warning-soft px-3 py-2 text-[12.5px] text-warning">
238 + <AlertTriangle className="size-3.5 shrink-0" />
239 + <span className="min-w-0 flex-1">
240 + {modelName} is {notice.kind === "deprecated" ? "deprecated" : `retiring on ${notice.shutdownDate}`}.
241 + </span>
242 + <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={() => actions.onSwitchModel?.(replacement.key)}>
243 + Switch to {replacement.displayName}
244 + </Button>
245 + </div>
246 + ) : null}
247 +
147 248 {(reasoning || (streaming && live!.reasoning)) && showReasoning ? <ReasoningBlock text={reasoning} streaming={streaming && !live!.text} durationMs={reasoningParts[0]?.durationMs} /> : null}
148 249
149 250 {serverTools.length ? (
@@ -171,7 +272,11 @@ export const MessageItem = React.memo(function MessageItem({ message: m, model,
171 272 </div>
172 273 ) : null}
173 274
174 − {text ? <Markdown content={text} wrap={wrapCode} streaming={streaming} /> : null}
275 + {text ? (
276 + <div {...longPress} className="select-text [-webkit-touch-callout:none]">
277 + <Markdown content={text} wrap={wrapCode} streaming={streaming} />
278 + </div>
279 + ) : null}
175 280
176 281 {refusal ? (
177 282 <div className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning-soft px-3 py-2 text-[13px] text-warning">
@@ -182,102 +287,75 @@ export const MessageItem = React.memo(function MessageItem({ message: m, model,
182 287 </div>
183 288 ) : null}
184 289
185 − {m.error && m.status === "error" ? (
186 − <div className="flex items-start gap-2 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">
187 − <X className="mt-0.5 size-4 shrink-0" />
188 − <div className="min-w-0">
189 − <div className="font-medium">{m.error.message}</div>
190 − <div className="font-mono text-[11px] opacity-80">{m.error.code}</div>
191 − </div>
192 − {actions?.onRetry && !busy ? (
193 − <Button size="xs" variant="outline" className="ml-auto shrink-0" onClick={() => actions.onRetry?.(m)}>
194 − <RefreshCw /> Retry
195 − </Button>
196 − ) : null}
197 − </div>
290 + {err && (m.status === "error" || (m.status === "stopped" && err.code !== "CANCELLED")) ? (
291 + <MessageError error={err} provider={err.provider ?? provider} requestId={requestId} since={errorAt} onRetry={actions?.onRetry && !busy ? () => actions.onRetry?.(m) : undefined} onSwitchModel={actions?.onRegenerateWith && !busy ? () => actions.onRegenerateWith?.(m) : undefined} />
198 292 ) : null}
199 293
200 294 {citations.length ? <Citations items={citations} /> : null}
201 295
202 296 {!streaming ? (
203 297 <div className="flex flex-wrap items-center gap-x-1 gap-y-1 pt-0.5 text-[11.5px] text-fg-subtle">
204 − <div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
205 − <IconBtn label={copied ? "Copied" : "Copy response"} onClick={copy}>
206 − {copied ? <Check className="text-success" /> : <Copy />}
207 − </IconBtn>
208 − {actions?.onRegenerate && !busy ? (
209 − <IconBtn label="Regenerate" onClick={() => actions.onRegenerate?.(m)}>
210 − <RefreshCw />
211 − </IconBtn>
212 − ) : null}
213 − {actions?.onContinue && isLast && !busy && (m.finishReason === "length" || m.status === "stopped") ? (
214 − <IconBtn label="Continue" onClick={() => actions.onContinue?.(m)}>
215 − <StepForward />
216 − </IconBtn>
217 − ) : null}
218 − {actions?.onBranch ? (
219 − <IconBtn label="Branch from here" onClick={() => actions.onBranch?.(m)}>
220 − <GitBranch />
221 − </IconBtn>
222 − ) : null}
223 − {actions?.onDelete && !busy ? (
224 − <IconBtn label="Delete" onClick={() => actions.onDelete?.(m)}>
225 − <Trash2 />
226 − </IconBtn>
227 − ) : null}
228 − </div>
298 + {toolbar}
229 299 <span className="ml-1 tabular-nums">{new Date(m.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
230 300 {usage ? (
231 301 <>
232 302 <Dot />
233 − <Tooltip content={<UsageTip usage={usage} />}>
234 − <span className="tabular-nums">
235 − {formatTokens(usage.inputTokens ?? 0)} in · {formatTokens(usage.outputTokens ?? 0)} out
236 − </span>
237 − </Tooltip>
238 − </>
239 − ) : null}
240 − {m.latencyMs ? (
241 − <>
242 − <Dot />
243 − <span className="tabular-nums" title={m.ttftMs ? `Time to first token ${formatMs(m.ttftMs)}` : undefined}>
244 − {formatMs(m.latencyMs)}
245 − {m.ttftMs ? ` · TTFT ${formatMs(m.ttftMs)}` : ""}
303 + <span className="tabular-nums">
304 + {formatTokens(usage.inputTokens ?? 0)} in · {formatTokens(usage.outputTokens ?? 0)} out
246 305 </span>
247 306 </>
248 307 ) : null}
249 − {tps ? (
250 − <>
251 − <Dot />
252 − <span className="tabular-nums">{tps} tok/s</span>
253 − </>
254 − ) : null}
255 308 {showCosts && m.costUsd !== null && m.costUsd !== undefined ? (
256 309 <>
257 310 <Dot />
258 − <span className="tabular-nums" title="Estimated from the provider's list price">
259 − ≈ {formatUsd(m.costUsd, { precise: m.costUsd < 0.01 })}
260 − </span>
311 + <span className="tabular-nums">≈ {formatUsd(m.costUsd, { precise: m.costUsd < 0.01 })}</span>
261 312 </>
262 313 ) : null}
314 + {usage || m.latencyMs ? (
315 + <button type="button" onClick={() => setDetailsOpen((o) => !o)} className="tap ml-1 inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-expanded={detailsOpen} aria-label="Toggle message details">
316 + Details {detailsOpen ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
317 + </button>
318 + ) : null}
263 319 </div>
264 320 ) : null}
321 +
322 + {detailsOpen && !streaming ? (
323 + <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-xl bg-bg-subtle/70 px-3 py-2.5 text-[12px] sm:grid-cols-3 md:grid-cols-4">
324 + <Meta k="Model" v={m.modelKey?.split("/").slice(1).join("/") ?? modelName} mono />
325 + <Meta k="Provider" v={provider ? PROVIDERS[provider as keyof typeof PROVIDERS]?.name ?? provider : "—"} />
326 + <Meta k="Input tokens" v={usage?.inputTokens !== undefined ? formatTokens(usage.inputTokens) : "—"} />
327 + <Meta k="Output tokens" v={usage?.outputTokens !== undefined ? formatTokens(usage.outputTokens) : "—"} />
328 + <Meta k="Reasoning tokens" v={usage?.reasoningTokens ? formatTokens(usage.reasoningTokens) : "—"} />
329 + <Meta k="Cached tokens" v={usage?.cachedInputTokens ? formatTokens(usage.cachedInputTokens) : "—"} />
330 + <Meta k="Cost" v={m.costUsd !== null && m.costUsd !== undefined ? `≈ ${formatUsd(m.costUsd, { precise: m.costUsd < 0.01 })}` : "—"} />
331 + <Meta k="Time to first token" v={formatMs(m.ttftMs)} />
332 + <Meta k="Total latency" v={formatMs(m.latencyMs)} />
333 + <Meta k="Speed" v={tps ? `${tps} tok/s` : "—"} />
334 + <Meta k="Reasoning effort" v={typeof settings?.reasoningEffort === "string" ? String(settings.reasoningEffort) : settings?.thinkingBudget ? `${formatTokens(settings.thinkingBudget as number)} budget` : "—"} />
335 + <Meta k="Finish reason" v={m.finishReason ?? "—"} mono />
336 + </dl>
337 + ) : null}
265 338 </div>
339 + {sheet}
266 340 </div>
267 341 );
268 342 });
269 343
344 +function toSheetItems(items: Item[]): ActionSheetItem[] {
345 + return items.map((i) => ({ key: i.key, label: i.label, icon: i.icon, onSelect: i.onSelect, destructive: i.destructive }));
346 +}
347 +
270 348 function Dot() {
271 349 return <span className="text-border-strong">·</span>;
272 350 }
273 351
274 −function UsageTip({ usage }: { usage: { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } }) {
352 +function Meta({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
275 353 return (
276 − <div className="space-y-0.5 tabular-nums">
277 − <div>Input: {formatTokens(usage.inputTokens ?? 0)}</div>
278 − {usage.cachedInputTokens ? <div>Cached: {formatTokens(usage.cachedInputTokens)}</div> : null}
279 − <div>Output: {formatTokens(usage.outputTokens ?? 0)}</div>
280 − {usage.reasoningTokens ? <div>Reasoning: {formatTokens(usage.reasoningTokens)}</div> : null}
354 + <div className="min-w-0">
355 + <dt className="text-[10.5px] uppercase tracking-wide text-fg-subtle">{k}</dt>
356 + <dd className={cn("truncate tabular-nums text-fg", mono && "font-mono text-[11.5px]")} title={v}>
357 + {v}
358 + </dd>
281 359 </div>
282 360 );
283 361 }
@@ -285,7 +363,7 @@ function UsageTip({ usage }: { usage: { inputTokens?: number; outputTokens?: num
285 363 function IconBtn({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
286 364 return (
287 365 <Tooltip content={label}>
288 − <button onClick={onClick} className="rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label={label}>
366 + <button onClick={onClick} className="tap rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label={label}>
289 367 {children}
290 368 </button>
291 369 </Tooltip>
@@ -307,7 +385,7 @@ function ReasoningBlock({ text, streaming, durationMs }: { text: string; streami
307 385 }, [text, open, streaming]);
308 386 return (
309 387 <div className="rounded-lg border border-border bg-bg-subtle/60">
310 − <button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-fg-muted hover:text-fg">
388 + <button onClick={() => setOpen((o) => !o)} className="flex min-h-[36px] w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-fg-muted hover:text-fg">
311 389 <Brain className={cn("size-3.5", streaming && "animate-pulse-soft text-accent")} />
312 390 <span className="font-medium">{streaming ? "Thinking…" : "Reasoning"}</span>
313 391 {durationMs ? <span className="text-fg-subtle">· {formatMs(durationMs)}</span> : null}
@@ -315,7 +393,7 @@ function ReasoningBlock({ text, streaming, durationMs }: { text: string; streami
315 393 </button>
316 394 {open ? (
317 395 <div ref={ref} className="max-h-72 overflow-y-auto border-t border-border px-3 py-2 text-[13px] leading-relaxed text-fg-muted scrollbar-thin">
318 − <Markdown content={text} className="text-[13px] text-fg-muted [&_p]:text-fg-muted" />
396 + <Markdown content={text} streaming={streaming} className="text-[13px] text-fg-muted [&_p]:text-fg-muted" />
319 397 </div>
320 398 ) : null}
321 399 </div>
@@ -327,7 +405,7 @@ function ToolCallCard({ call }: { call: { id: string; name: string; args: string
327 405 const pending = call.result === undefined;
328 406 return (
329 407 <div className="rounded-lg border border-border bg-bg-subtle/60 text-[12.5px]">
330 − <button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-fg-muted hover:text-fg">
408 + <button onClick={() => setOpen((o) => !o)} className="flex min-h-[36px] w-full items-center gap-2 px-3 py-1.5 text-left text-fg-muted hover:text-fg">
331 409 <Wrench className={cn("size-3.5", pending && "animate-pulse-soft text-accent")} />
332 410 <span className="font-medium font-mono">{call.name}</span>
333 411 {pending ? <span className="text-fg-subtle">running…</span> : call.isError ? <Badge variant="danger">error</Badge> : <Badge variant="success">done</Badge>}
@@ -373,8 +451,8 @@ function Citations({ items }: { items: { url?: string; title?: string; snippet?:
373 451 <div className="flex flex-wrap gap-1.5 pt-1">
374 452 {unique.slice(0, 12).map((c, i) => (
375 453 <a key={i} href={c.url} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-[260px] items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11.5px] text-fg-muted hover:border-border-strong hover:text-fg" title={c.snippet}>
376 − <ExternalLink className="size-3 shrink-0" />
377 − <span className="truncate">{c.title || safeHost(c.url)}</span>
454 + <ExternalLink className="size-3 shrink-0" />
455 + <span className="truncate">{c.title || safeHost(c.url)}</span>
378 456 </a>
379 457 ))}
380 458 </div>
@@ -403,7 +481,7 @@ export function AttachmentChip({ a, onRemove }: { a: { attachmentId?: string; id
403 481 {/* eslint-disable-next-line @next/next/no-img-element */}
404 482 <img src={`/api/attachments?id=${id}`} alt={a.name} className="max-h-40 max-w-[240px] rounded-lg border border-border object-cover" />
405 483 {onRemove ? (
406 − <button onClick={onRemove} className="absolute -right-1.5 -top-1.5 rounded-full border border-border bg-bg-elevated p-0.5 text-fg-muted hover:text-danger" aria-label="Remove attachment">
484 + <button onClick={onRemove} className="tap absolute -right-1.5 -top-1.5 rounded-full border border-border bg-bg-elevated p-0.5 text-fg-muted hover:text-danger" aria-label="Remove attachment">
407 485 <X className="size-3" />
408 486 </button>
409 487 ) : null}
@@ -416,7 +494,7 @@ export function AttachmentChip({ a, onRemove }: { a: { attachmentId?: string; id
416 494 <span className="max-w-[180px] truncate">{a.name}</span>
417 495 <span className="text-fg-subtle">{(a.sizeBytes / 1024).toFixed(0)} KB</span>
418 496 {onRemove ? (
419 − <button onClick={onRemove} className="ml-0.5 rounded p-0.5 text-fg-subtle hover:text-danger" aria-label="Remove attachment">
497 + <button onClick={onRemove} className="tap ml-0.5 rounded p-0.5 text-fg-subtle hover:text-danger" aria-label="Remove attachment">
420 498 <X className="size-3" />
421 499 </button>
422 500 ) : null}
modified src/components/chat/model-badges.tsx +60 −23
@@ -4,24 +4,56 @@ import { Brain, Eye, FileText, Globe, Wrench, Braces, Zap, Maximize2 } from "luc
4 4 import type { PolyModel } from "@/lib/client/types";
5 5 import { Badge } from "@/components/ui/badge";
6 6 import { Tooltip } from "@/components/ui/tooltip";
7 −import { formatTokens, formatUsd } from "@/lib/utils";
7 +import { useApp } from "@/components/app/store";
8 +import { BadgeChips, LifecycleChip } from "@/components/models/badge-chips";
9 +import { buildBadgeContext, deriveBadges, isFastModel as isFastModelBase, lifecycleStatus, retiresSoon as retiresSoonBase, shutdownDateOf, LONG_CONTEXT_TOKENS, type BadgeContext } from "@/lib/models/badges";
10 +import { formatPrice } from "@/lib/models/format";
11 +import { formatTokens } from "@/lib/utils";
8 12 import { cn } from "@/lib/utils";
9 13
14 +/** Capability glyph definitions shared by rows, profile and catalog (order = display order). */
15 +export const CAPABILITY_GLYPHS: { key: keyof PolyModel["capabilities"]; label: string; icon: React.ReactNode; title: string }[] = [
16 + { key: "vision", label: "Vision", icon: <Eye />, title: "Understands images" },
17 + { key: "reasoning", label: "Reasoning", icon: <Brain />, title: "Extended reasoning / thinking" },
18 + { key: "tools", label: "Tools", icon: <Wrench />, title: "Function / tool calling" },
19 + { key: "webSearch", label: "Web", icon: <Globe />, title: "Provider-native web search" },
20 + { key: "structuredOutput", label: "JSON", icon: <Braces />, title: "Structured output (JSON schema)" },
21 + { key: "files", label: "PDF", icon: <FileText />, title: "PDF / file input" },
22 +];
23 +
10 24 export function capabilityList(m: PolyModel) {
11 − const c = m.capabilities;
12 − const out: { key: string; label: string; icon: React.ReactNode; title: string }[] = [];
13 − if (c.reasoning) out.push({ key: "reasoning", label: "Reasoning", icon: <Brain />, title: "Extended reasoning / thinking" });
14 − if (c.vision) out.push({ key: "vision", label: "Vision", icon: <Eye />, title: "Understands images" });
15 − if (c.tools) out.push({ key: "tools", label: "Tools", icon: <Wrench />, title: "Function / tool calling" });
16 − if (c.structuredOutput) out.push({ key: "json", label: "JSON", icon: <Braces />, title: "Structured output (JSON schema)" });
17 − if (c.webSearch) out.push({ key: "web", label: "Web", icon: <Globe />, title: "Provider-native web search" });
18 − if (c.files) out.push({ key: "files", label: "PDF", icon: <FileText />, title: "PDF / file input" });
19 − if ((m.limits?.contextTokens ?? 0) >= 400_000) out.push({ key: "long", label: formatTokens(m.limits!.contextTokens!), icon: <Maximize2 />, title: "Long context window" });
25 + const out: { key: string; label: string; icon: React.ReactNode; title: string }[] = CAPABILITY_GLYPHS.filter((g) => m.capabilities[g.key]).map((g) => ({ key: g.key, label: g.label, icon: g.icon, title: g.title }));
26 + if ((m.limits?.contextTokens ?? 0) >= LONG_CONTEXT_TOKENS) out.push({ key: "long", label: formatTokens(m.limits!.contextTokens!), icon: <Maximize2 />, title: "Long context window" });
20 27 return out;
21 28 }
22 29
30 +/** Kept for existing callers (arena auto-pick). Prefer `speedTier()` from `lib/models/badges` for new code. */
23 31 export function isFastModel(m: PolyModel): boolean {
24 − return /flash|mini|nano|haiku|fast|lite|luna|non-reasoning/i.test(m.id) || (m.pricing?.outputPerMillion ?? 99) <= 2;
32 + return isFastModelBase(m);
33 +}
34 +
35 +export const retiresSoon = retiresSoonBase;
36 +
37 +/** Registry-wide badge context (pricing quantiles, "new" window) memoized on the store's model list. */
38 +export function useBadgeContext(): BadgeContext {
39 + const { models } = useApp();
40 + const [now] = React.useState(() => Date.now());
41 + return React.useMemo(() => buildBadgeContext(models, now), [models, now]);
42 +}
43 +
44 +/** Compact icon-only capability row: Vision · Reasoning · Tools · Web · JSON · PDF. */
45 +export function CapabilityGlyphs({ model, max = 6, className, size = "sm" }: { model: PolyModel; max?: number; className?: string; size?: "sm" | "md" }) {
46 + const on = CAPABILITY_GLYPHS.filter((g) => model.capabilities[g.key]).slice(0, max);
47 + if (!on.length) return <span className={cn("text-[11px] text-fg-subtle", className)}>text only</span>;
48 + return (
49 + <span className={cn("inline-flex items-center gap-0.5", className)} aria-label={on.map((g) => g.label).join(", ")}>
50 + {on.map((g) => (
51 + <Tooltip key={g.key} content={g.title}>
52 + <span className={cn("flex items-center justify-center rounded-md text-fg-muted", size === "sm" ? "size-5 [&_svg]:size-3" : "size-6 bg-bg-muted [&_svg]:size-3.5")}>{g.icon}</span>
53 + </Tooltip>
54 + ))}
55 + </span>
56 + );
25 57 }
26 58
27 59 export function CapabilityBadges({ model, compact, max = 6, className }: { model: PolyModel; compact?: boolean; max?: number; className?: string }) {
@@ -46,25 +78,30 @@ export function CapabilityBadges({ model, compact, max = 6, className }: { model
46 78 );
47 79 }
48 80
81 +/** NEW / FAST / CHEAP / REASONING / VISION / CODING / LONG CONTEXT from real registry data. */
82 +export function ModelBadges({ model, ctx, max = 3, className, size }: { model: PolyModel; ctx: BadgeContext; max?: number; className?: string; size?: "xs" | "sm" }) {
83 + const badges = React.useMemo(() => deriveBadges(model, ctx), [model, ctx]);
84 + return <BadgeChips badges={badges} max={max} className={className} size={size} />;
85 +}
86 +
49 87 export function PriceLabel({ model, className }: { model: PolyModel; className?: string }) {
50 88 const p = model.pricing;
51 89 if (!p || (p.inputPerMillion === undefined && p.outputPerMillion === undefined)) return <span className={cn("text-fg-subtle", className)}>price n/a</span>;
52 90 return (
53 − <span className={cn("tabular-nums text-fg-subtle", className)} title="USD per 1M tokens — input / output (estimate)">
54 − {formatUsd(p.inputPerMillion ?? 0)} / {formatUsd(p.outputPerMillion ?? 0)} per 1M
91 + <span className={cn("tabular-nums text-fg-subtle", className)} title="USD per 1M tokens — input / output">
92 + {formatPrice(p.inputPerMillion)} / {formatPrice(p.outputPerMillion)} per 1M
55 93 </span>
56 94 );
57 95 }
58 96
59 −export function retiresSoon(shutdownDate: string | null | undefined, now: number): boolean {
60 − return Boolean(shutdownDate) && new Date(shutdownDate!).getTime() - now < 90 * 86_400_000;
61 −}
62 −
63 −export function StatusBadge({ status, shutdownDate }: { status: PolyModel["status"]; shutdownDate?: string | null }) {
97 +/**
98 + * Lifecycle badge: New / Active / Preview / Deprecated / Retiring / Unavailable.
99 + * `showActive` renders the green "Active" chip too (catalog); otherwise Active/New-less models render nothing.
100 + */
101 +export function StatusBadge({ model, connected, ctx, showActive = false, className }: { model: PolyModel; connected?: boolean; ctx?: BadgeContext; showActive?: boolean; className?: string }) {
64 102 const [now] = React.useState(() => Date.now());
65 − if (retiresSoon(shutdownDate, now)) return <Badge variant="warning">Retires {shutdownDate}</Badge>;
66 − if (status === "preview") return <Badge variant="info">Preview</Badge>;
67 − if (status === "deprecated") return <Badge variant="danger">Deprecated</Badge>;
68 − if (status === "unknown") return <Badge variant="outline">Unverified</Badge>;
69 − return null;
103 + const lc = lifecycleStatus(model, { connected, ctx, now });
104 + if (lc.status === "active" && !showActive) return null;
105 + const sd = shutdownDateOf(model);
106 + return <LifecycleChip lifecycle={lc.status === "retiring" && sd ? { ...lc, label: `Retires ${sd}` } : lc} className={className} />;
70 107 }
modified src/components/chat/model-config.tsx +354 −227
@@ -1,34 +1,48 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { Braces, Calculator, Clock, Dices, Globe, RotateCcw, Settings2, Terminal, Save } from "lucide-react";
4 −import type { PolyModel, GenerationSettingsInput } from "@/lib/client/types";
3 +import { Bookmark, Braces, Calculator, ChevronDown, Clock, Dices, Globe, RotateCcw, Save, Settings2, SlidersHorizontal, Sparkles, Terminal, Wrench, X } from "lucide-react";
4 +import type { PolyModel, GenerationSettingsInput, ModelPreset } from "@/lib/client/types";
5 5 import { Button } from "@/components/ui/button";
6 6 import { Input, Textarea, Label } from "@/components/ui/input";
7 7 import { Slider } from "@/components/ui/slider";
8 8 import { Switch } from "@/components/ui/switch";
9 +import { Segmented } from "@/components/ui/segmented";
9 10 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
10 −import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
11 +import { ResponsiveDialog } from "@/components/ui/sheet";
11 12 import { Tooltip } from "@/components/ui/tooltip";
12 13 import { Badge } from "@/components/ui/badge";
13 −import { api } from "@/lib/client/api";
14 +import { EmptyState, Skeleton } from "@/components/ui/misc";
15 +import { PromptDialog } from "@/components/common/prompt-dialog";
16 +import { ProviderIcon } from "@/components/brand/provider-icon";
17 +import { useApp } from "@/components/app/store";
18 +import { api, useApi } from "@/lib/client/api";
19 +import { errorMessage } from "@/lib/client/humanize";
14 20 import { toast } from "@/components/ui/toast";
21 +import { groupsFor, isSetValue, presetCompatibility, presetToSettings, PARAM_DEFS, type SettingsGroup } from "@/lib/models/params";
15 22 import { cn, formatTokens } from "@/lib/utils";
16 23
17 24 export type ChatSettings = GenerationSettingsInput & { tools?: string[] };
18 25
19 −const EFFORT_LABEL: Record<string, string> = { none: "None (no reasoning)", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum" };
26 +const EFFORT_LABEL: Record<string, string> = { none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum" };
27 +const GROUP_ICON: Record<SettingsGroup, React.ReactNode> = { generation: <SlidersHorizontal />, reasoning: <Sparkles />, output: <Braces />, tools: <Wrench />, advanced: <Settings2 /> };
28 +const DEFAULT_OPEN: SettingsGroup[] = ["generation", "reasoning"];
29 +const UNSET = "__default";
20 30
21 31 export function countActiveSettings(s: ChatSettings | undefined): number {
22 32 if (!s) return 0;
23 − return Object.entries(s).filter(([k, v]) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0) && k !== "includeReasoning" && !(k === "responseFormat" && (v as { type: string }).type === "text")).length;
33 + return Object.entries(s).filter(([k, v]) => isSetValue(k, v)).length;
24 34 }
25 35
26 36 /**
27 − * Capability-driven model configuration. Every control is gated by the model's
28 − * parameter sheet; unsupported options are simply not rendered (with a short note).
37 + * Capability-driven model configuration, grouped Generation / Reasoning / Output / Tools / Advanced.
38 + * Only controls the model supports are rendered; unsupported ones are listed at the bottom and never sent.
39 + * Phone: full bottom sheet. Desktop: right-side panel so the conversation stays visible.
29 40 */
30 41 export function ModelConfig({ model, settings, onChange, systemPrompt, onSystemPromptChange, trigger, allowSavePreset = true }: { model: PolyModel | undefined; settings: ChatSettings; onChange: (s: ChatSettings) => void; systemPrompt: string; onSystemPromptChange: (v: string) => void; trigger?: React.ReactNode; allowSavePreset?: boolean }) {
31 42 const [open, setOpen] = React.useState(false);
43 + const [presetsOpen, setPresetsOpen] = React.useState(false);
44 + const [saveOpen, setSaveOpen] = React.useState(false);
45 + const [openGroups, setOpenGroups] = React.useState<Set<SettingsGroup>>(() => new Set(DEFAULT_OPEN));
32 46 const p = model?.parameters ?? {};
33 47 const c = model?.capabilities;
34 48 const set = <K extends keyof ChatSettings>(k: K, v: ChatSettings[K]) => onChange({ ...settings, [k]: v });
@@ -38,34 +52,116 @@ export function ModelConfig({ model, settings, onChange, systemPrompt, onSystemP
38 52 onChange(next);
39 53 };
40 54 const active = countActiveSettings(settings);
55 + const groups = React.useMemo(() => (model ? groupsFor(model) : []), [model]);
56 + const unsupported = React.useMemo(() => (model ? PARAM_DEFS.filter((d) => !d.supports(model)).map((d) => d.label) : []), [model]);
57 + const toggleGroup = (g: SettingsGroup) =>
58 + setOpenGroups((prev) => {
59 + const n = new Set(prev);
60 + if (n.has(g)) n.delete(g);
61 + else n.add(g);
62 + return n;
63 + });
41 64
42 − const savePreset = async () => {
65 + const savePreset = async (name: string) => {
43 66 if (!model) return;
44 − const name = prompt("Preset name", `${model.displayName} custom`);
45 − if (!name?.trim()) return;
46 67 const { tools, ...parameters } = settings;
47 − await api("/api/presets?kind=model", { method: "POST", json: { name: name.trim(), modelKey: model.key, systemPrompt: systemPrompt || null, parameters, tools: { builtin: tools ?? [] } } });
48 − toast.success("Preset saved", "Find it under Presets.");
68 + try {
69 + await api("/api/presets?kind=model", { method: "POST", json: { name, modelKey: model.key, systemPrompt: systemPrompt || null, parameters, tools: { builtin: tools ?? [] } } });
70 + toast.success("Preset saved", "Find it under Presets, or apply it from here.");
71 + } catch (e) {
72 + toast.error("Could not save preset", errorMessage(e));
73 + throw e;
74 + }
75 + };
76 +
77 + const applyPreset = (preset: ModelPreset) => {
78 + const next = presetToSettings(preset) as ChatSettings;
79 + onChange(next);
80 + if (preset.systemPrompt) onSystemPromptChange(preset.systemPrompt);
81 + setPresetsOpen(false);
82 + toast.success(`Applied “${preset.name}”`, preset.systemPrompt ? "Parameters and system prompt updated." : "Parameters updated.");
49 83 };
50 84
51 − const unsupported: string[] = [];
52 − if (model) {
53 − if (!p.temperature) unsupported.push("temperature");
54 − if (!p.topP) unsupported.push("top-p");
55 − if (!p.topK) unsupported.push("top-k");
56 − if (!p.frequencyPenalty) unsupported.push("penalties");
57 − if (!p.seed) unsupported.push("seed");
58 − if (!p.stop) unsupported.push("stop sequences");
59 − if (!p.reasoningEffort && !p.thinkingBudget) unsupported.push("reasoning controls");
60 − if (!c?.structuredOutput) unsupported.push("structured output");
61 − if (!c?.tools) unsupported.push("tools");
62 − if (!c?.webSearch) unsupported.push("web search");
63 − }
85 + const samplingNote = model?.metadata?.samplingMode === "conditional" && settings.reasoningEffort !== "none" ? "Temperature, top-p and penalties are accepted only when reasoning effort is “None”. They are dropped otherwise." : null;
86 + const activeIn = (g: SettingsGroup) => PARAM_DEFS.filter((d) => d.group === g && isSetValue(d.key, (settings as Record<string, unknown>)[d.key])).length;
64 87
65 − const samplingNote = model?.metadata?.samplingMode === "conditional" && settings.reasoningEffort !== "none" ? "This model accepts temperature / top-p / penalties only when reasoning effort is set to “None”. They are dropped otherwise." : null;
88 + const renderControl = (key: string) => {
89 + if (!model) return null;
90 + switch (key) {
91 + case "temperature":
92 + return <SliderRow key={key} label="Temperature" hint="Lower is more deterministic" value={settings.temperature} min={p.temperatureRange?.min ?? 0} max={p.temperatureRange?.max ?? 2} step={0.05} onChange={(v) => (v === undefined ? unset("temperature") : set("temperature", v))} />;
93 + case "topP":
94 + return <SliderRow key={key} label="Top-p" hint="Nucleus sampling cutoff" value={settings.topP} min={0} max={1} step={0.01} onChange={(v) => (v === undefined ? unset("topP") : set("topP", v))} />;
95 + case "topK":
96 + return <SliderRow key={key} label="Top-k" value={settings.topK} min={1} max={200} step={1} onChange={(v) => (v === undefined ? unset("topK") : set("topK", v))} />;
97 + case "maxTokens":
98 + return (
99 + <NumberRow key={key} label="Max output tokens" hint={model.limits?.maxOutputTokens ? `Up to ${formatTokens(model.limits.maxOutputTokens)}${c?.reasoning ? " — reasoning tokens count against it" : ""}` : undefined} value={settings.maxTokens} min={16} max={model.limits?.maxOutputTokens ?? 400_000} placeholder="default" onChange={(v) => (v === undefined ? unset("maxTokens") : set("maxTokens", v))} />
100 + );
101 + case "reasoningEffort": {
102 + const levels = p.reasoningEffortLevels ?? [];
103 + return <ChoiceRow key={key} label="Reasoning effort" hint={model.metadata?.defaultReasoningEffort ? `Provider default: ${String(model.metadata.defaultReasoningEffort)}` : undefined} value={settings.reasoningEffort} options={levels.map((l) => ({ value: l, label: EFFORT_LABEL[l] ?? l }))} onChange={(v) => (v === undefined ? unset("reasoningEffort") : set("reasoningEffort", v as ChatSettings["reasoningEffort"]))} />;
104 + }
105 + case "thinkingBudget": {
106 + const r = p.thinkingBudgetRange ?? { min: 0, max: 32_000 };
107 + return <SliderRow key={key} label="Thinking budget" hint={`${formatTokens(r.min)}–${formatTokens(r.max)} tokens`} value={settings.thinkingBudget} min={r.min} max={r.max} step={Math.max(1, Math.round((r.max - r.min) / 256))} onChange={(v) => (v === undefined ? unset("thinkingBudget") : set("thinkingBudget", Math.round(v)))} />;
108 + }
109 + case "includeReasoning":
110 + return <SwitchRow key={key} label="Show reasoning" hint="Stream the model's reasoning summary when the provider exposes it" checked={settings.includeReasoning !== false} onChange={(v) => (v ? unset("includeReasoning") : set("includeReasoning", false))} />;
111 + case "verbosity":
112 + return <ChoiceRow key={key} label="Verbosity" value={settings.verbosity} options={["low", "medium", "high"].map((l) => ({ value: l, label: EFFORT_LABEL[l] }))} onChange={(v) => (v === undefined ? unset("verbosity") : set("verbosity", v as ChatSettings["verbosity"]))} />;
113 + case "responseFormat":
114 + return (
115 + <div key={key} className="space-y-2">
116 + <ChoiceRow label="Response format" value={settings.responseFormat?.type ?? "text"} noDefault options={[{ value: "text", label: "Text" }, { value: "json", label: "JSON object" }, ...(model.metadata?.jsonSchema === false ? [] : [{ value: "json_schema", label: "JSON schema" }])]} onChange={(v) => (!v || v === "text" ? unset("responseFormat") : set("responseFormat", { type: v as "json" | "json_schema", schema: settings.responseFormat?.schema, strict: true }))} />
117 + {settings.responseFormat?.type === "json_schema" ? <SchemaEditor value={settings.responseFormat.schema} onChange={(schema) => set("responseFormat", { type: "json_schema", schema, strict: true })} /> : null}
118 + </div>
119 + );
120 + case "stop":
121 + return (
122 + <FieldRow key={key} label="Stop sequences" hint="Comma-separated, up to 4">
123 + <Input className="h-10 sm:h-8 sm:w-56 sm:text-[13px]" value={(settings.stop ?? []).join(", ")} onChange={(e) => set("stop", e.target.value.split(",").map((s) => s.trim()).filter(Boolean).slice(0, 4))} placeholder="e.g. END, ###" />
124 + </FieldRow>
125 + );
126 + case "webSearch":
127 + return <SwitchRow key={key} icon={<Globe />} label="Web search" hint="Provider-native search with citations (billed by the provider)" checked={Boolean(settings.webSearch)} onChange={(v) => (v ? set("webSearch", true) : unset("webSearch"))} />;
128 + case "codeExecution":
129 + return <SwitchRow key={key} icon={<Terminal />} label="Code execution" hint="Provider-hosted sandbox (not combinable with web search on Anthropic)" checked={Boolean(settings.codeExecution)} onChange={(v) => (v ? set("codeExecution", true) : unset("codeExecution"))} />;
130 + case "tools":
131 + return (
132 + <div key={key} className="space-y-2">
133 + <p className="text-[12px] text-fg-muted">Built-in PolyLLM tools (server-side, side-effect free)</p>
134 + <div className="flex flex-wrap gap-1.5">
135 + {[
136 + { id: "calculator", label: "Calculator", icon: <Calculator className="size-3.5" /> },
137 + { id: "clock", label: "Clock", icon: <Clock className="size-3.5" /> },
138 + { id: "random", label: "Random", icon: <Dices className="size-3.5" /> },
139 + ].map((t) => {
140 + const on = settings.tools?.includes(t.id);
141 + return (
142 + <button key={t.id} type="button" aria-pressed={on} onClick={() => set("tools", on ? (settings.tools ?? []).filter((x) => x !== t.id) : [...(settings.tools ?? []), t.id])} className={cn("inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-[13px] transition-colors sm:h-8", on ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-border-strong")}>
143 + {t.icon} {t.label}
144 + </button>
145 + );
146 + })}
147 + </div>
148 + </div>
149 + );
150 + case "toolChoice":
151 + return settings.tools?.length ? <ChoiceRow key={key} label="Tool choice" noDefault value={typeof settings.toolChoice === "string" ? settings.toolChoice : "auto"} options={[{ value: "auto", label: "Auto" }, { value: "required", label: "Required" }, { value: "none", label: "None" }]} onChange={(v) => set("toolChoice", (v ?? "auto") as "auto" | "none" | "required")} /> : null;
152 + case "seed":
153 + return <NumberRow key={key} label="Seed" hint="Best-effort determinism" value={settings.seed} min={0} max={2_147_483_647} placeholder="random" onChange={(v) => (v === undefined ? unset("seed") : set("seed", v))} />;
154 + case "frequencyPenalty":
155 + return <SliderRow key={key} label="Frequency penalty" value={settings.frequencyPenalty} min={-2} max={2} step={0.1} onChange={(v) => (v === undefined ? unset("frequencyPenalty") : set("frequencyPenalty", v))} />;
156 + case "presencePenalty":
157 + return <SliderRow key={key} label="Presence penalty" value={settings.presencePenalty} min={-2} max={2} step={0.1} onChange={(v) => (v === undefined ? unset("presencePenalty") : set("presencePenalty", v))} />;
158 + default:
159 + return null;
160 + }
161 + };
66 162
67 163 return (
68 − <Dialog open={open} onOpenChange={setOpen}>
164 + <>
69 165 <span onClick={() => setOpen(true)} className="contents">
70 166 {trigger ?? (
71 167 <Tooltip content="Model configuration">
@@ -77,236 +173,267 @@ export function ModelConfig({ model, settings, onChange, systemPrompt, onSystemP
77 173 </Tooltip>
78 174 )}
79 175 </span>
80 − <DialogContent size="lg">
81 − <DialogHeader>
82 − <DialogTitle className="flex items-center gap-2">
83 − Configure {model?.displayName ?? "model"}
84 − {model?.limits?.contextTokens ? <Badge variant="outline">{formatTokens(model.limits.contextTokens)} context</Badge> : null}
85 − </DialogTitle>
86 − <DialogDescription>Only settings supported by this model are shown. Unsupported options are never sent to the provider.</DialogDescription>
87 − </DialogHeader>
88 − <DialogBody className="space-y-5">
176 +
177 + <ResponsiveDialog
178 + open={open}
179 + onOpenChange={setOpen}
180 + desktop="panel"
181 + snap="full"
182 + title={
183 + <span className="flex items-center gap-2">
184 + {model ? <ProviderIcon provider={model.provider} size={16} /> : null}
185 + <span className="truncate">Configure {model?.displayName ?? "model"}</span>
186 + {model?.limits?.contextTokens ? <Badge variant="outline">{formatTokens(model.limits.contextTokens)} ctx</Badge> : null}
187 + </span>
188 + }
189 + description="Only settings this model supports are shown. Nothing else is ever sent to the provider."
190 + footer={
191 + <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
192 + <Button variant="ghost" size="sm" className="h-10 justify-start sm:h-8" onClick={() => onChange({})} disabled={active === 0}>
193 + <RotateCcw /> Reset to defaults
194 + </Button>
195 + <div className="grid grid-cols-2 gap-2 sm:flex">
196 + <Button variant="outline" size="sm" className="h-10 sm:h-8" onClick={() => setPresetsOpen(true)} disabled={!model}>
197 + <Bookmark /> Apply preset
198 + </Button>
199 + {allowSavePreset ? (
200 + <Button variant="outline" size="sm" className="h-10 sm:h-8" onClick={() => setSaveOpen(true)} disabled={!model}>
201 + <Save /> Save preset
202 + </Button>
203 + ) : (
204 + <Button size="sm" className="h-10 sm:h-8" onClick={() => setOpen(false)}>
205 + Done
206 + </Button>
207 + )}
208 + </div>
209 + </div>
210 + }
211 + >
212 + <div className="space-y-3 pt-1">
89 213 <section className="space-y-1.5">
90 214 <Label htmlFor="sys">System prompt</Label>
91 215 <Textarea id="sys" value={systemPrompt} onChange={(e) => onSystemPromptChange(e.target.value)} placeholder="Instructions that apply to the whole conversation…" rows={4} className="min-h-[96px]" />
92 216 </section>
93 217
94 − {(p.reasoningEffort || p.thinkingBudget || c?.reasoning) && model ? (
95 − <section className="space-y-3 rounded-lg border border-border p-3">
96 − <h4 className="text-[13px] font-semibold">Reasoning</h4>
97 − {p.reasoningEffort && p.reasoningEffortLevels?.length ? (
98 − <Row label="Effort" hint={model.metadata?.defaultReasoningEffort ? `Default: ${model.metadata.defaultReasoningEffort}` : undefined}>
99 − <Select value={settings.reasoningEffort ?? "__default"} onValueChange={(v) => (v === "__default" ? unset("reasoningEffort") : set("reasoningEffort", v as ChatSettings["reasoningEffort"]))}>
100 − <SelectTrigger className="h-8 w-48 text-[13px]">
101 − <SelectValue />
102 − </SelectTrigger>
103 − <SelectContent>
104 − <SelectItem value="__default">Provider default</SelectItem>
105 − {p.reasoningEffortLevels.map((l) => (
106 − <SelectItem key={l} value={l}>
107 − {EFFORT_LABEL[l] ?? l}
108 − </SelectItem>
109 − ))}
110 − </SelectContent>
111 − </Select>
112 − </Row>
113 − ) : null}
114 − {p.thinkingBudget ? (
115 − <Row label="Thinking budget" hint={`${p.thinkingBudgetRange?.min ?? 0}–${formatTokens(p.thinkingBudgetRange?.max ?? 32000)} tokens`}>
116 − <NumberInput value={settings.thinkingBudget} onChange={(v) => (v === undefined ? unset("thinkingBudget") : set("thinkingBudget", v))} min={p.thinkingBudgetRange?.min ?? 0} max={p.thinkingBudgetRange?.max ?? 200_000} placeholder="off" />
117 − </Row>
118 − ) : null}
119 − {c?.reasoning ? (
120 − <Row label="Show reasoning" hint="Stream the model's reasoning summary when the provider exposes it">
121 − <Switch checked={settings.includeReasoning !== false} onCheckedChange={(v) => set("includeReasoning", v)} />
122 − </Row>
123 − ) : null}
124 − {model.metadata?.alwaysReasoning ? <p className="text-xs text-fg-subtle">This model always reasons; effort only changes how much.</p> : null}
125 − </section>
126 − ) : null}
218 + {!model ? <EmptyState title="Pick a model first" description="Settings depend on what the selected model supports." /> : null}
219 + {samplingNote ? <p className="rounded-md bg-warning-soft px-2.5 py-1.5 text-xs text-warning">{samplingNote}</p> : null}
127 220
128 − <section className="space-y-3 rounded-lg border border-border p-3">
129 − <h4 className="text-[13px] font-semibold">Generation</h4>
130 − {samplingNote ? <p className="rounded-md bg-warning-soft px-2.5 py-1.5 text-xs text-warning">{samplingNote}</p> : null}
131 − {p.temperature ? (
132 − <SliderRow label="Temperature" value={settings.temperature} min={p.temperatureRange?.min ?? 0} max={p.temperatureRange?.max ?? 2} step={0.05} onChange={(v) => (v === undefined ? unset("temperature") : set("temperature", v))} />
133 − ) : null}
134 − {p.topP ? <SliderRow label="Top P" value={settings.topP} min={0} max={1} step={0.01} onChange={(v) => (v === undefined ? unset("topP") : set("topP", v))} /> : null}
135 − {p.topK ? (
136 − <Row label="Top K">
137 − <NumberInput value={settings.topK} onChange={(v) => (v === undefined ? unset("topK") : set("topK", v))} min={1} max={500} placeholder="default" />
138 − </Row>
139 − ) : null}
140 − {p.maxTokens ? (
141 − <Row label="Max output tokens" hint={model?.limits?.maxOutputTokens ? `Up to ${formatTokens(model.limits.maxOutputTokens)}${c?.reasoning ? " — reasoning tokens count against it" : ""}` : undefined}>
142 − <NumberInput value={settings.maxTokens} onChange={(v) => (v === undefined ? unset("maxTokens") : set("maxTokens", v))} min={16} max={model?.limits?.maxOutputTokens ?? 400_000} placeholder="default" />
143 − </Row>
144 − ) : null}
145 − {p.verbosity ? (
146 − <Row label="Verbosity">
147 − <Select value={settings.verbosity ?? "__default"} onValueChange={(v) => (v === "__default" ? unset("verbosity") : set("verbosity", v as ChatSettings["verbosity"]))}>
148 − <SelectTrigger className="h-8 w-40 text-[13px]">
149 − <SelectValue />
150 − </SelectTrigger>
151 − <SelectContent>
152 − <SelectItem value="__default">Default</SelectItem>
153 − <SelectItem value="low">Low</SelectItem>
154 − <SelectItem value="medium">Medium</SelectItem>
155 − <SelectItem value="high">High</SelectItem>
156 − </SelectContent>
157 − </Select>
158 − </Row>
159 − ) : null}
160 − {p.frequencyPenalty ? <SliderRow label="Frequency penalty" value={settings.frequencyPenalty} min={-2} max={2} step={0.1} onChange={(v) => (v === undefined ? unset("frequencyPenalty") : set("frequencyPenalty", v))} /> : null}
161 − {p.presencePenalty ? <SliderRow label="Presence penalty" value={settings.presencePenalty} min={-2} max={2} step={0.1} onChange={(v) => (v === undefined ? unset("presencePenalty") : set("presencePenalty", v))} /> : null}
162 − {p.seed ? (
163 − <Row label="Seed" hint="Best-effort determinism">
164 − <NumberInput value={settings.seed} onChange={(v) => (v === undefined ? unset("seed") : set("seed", v))} min={0} max={2_147_483_647} placeholder="random" />
165 − </Row>
166 − ) : null}
167 − {p.stop ? (
168 − <Row label="Stop sequences" hint="Comma-separated, up to 4">
169 − <Input className="h-8 w-56 text-[13px]" value={(settings.stop ?? []).join(", ")} onChange={(e) => set("stop", e.target.value.split(",").map((s) => s.trim()).filter(Boolean).slice(0, 4))} placeholder="e.g. END, ###" />
170 − </Row>
171 − ) : null}
172 − </section>
221 + {groups.map(({ group, defs }) => {
222 + const isOpen = openGroups.has(group.key);
223 + const n = activeIn(group.key);
224 + return (
225 + <section key={group.key} className="panel">
226 + <button type="button" onClick={() => toggleGroup(group.key)} aria-expanded={isOpen} className="flex min-h-11 w-full items-center gap-2.5 px-3 text-left">
227 + <span className="text-fg-muted [&_svg]:size-4">{GROUP_ICON[group.key]}</span>
228 + <span className="flex-1">
229 + <span className="block text-[13.5px] font-semibold">{group.label}</span>
230 + {!isOpen ? <span className="block text-[11.5px] text-fg-subtle">{defs.map((d) => d.label).join(" · ")}</span> : null}
231 + </span>
232 + {n > 0 ? <Badge variant="accent">{n}</Badge> : null}
233 + <ChevronDown className={cn("size-4 text-fg-subtle transition-transform", isOpen && "rotate-180")} />
234 + </button>
235 + {isOpen ? (
236 + <div className="space-y-4 px-3 pb-3.5">
237 + {defs.map((d) => renderControl(d.key))}
238 + {group.key === "reasoning" && model?.metadata?.alwaysReasoning ? <p className="text-[11.5px] text-fg-subtle">This model always reasons; effort only changes how much.</p> : null}
239 + </div>
240 + ) : null}
241 + </section>
242 + );
243 + })}
173 244
174 − {c?.structuredOutput ? (
175 − <section className="space-y-3 rounded-lg border border-border p-3">
176 − <h4 className="flex items-center gap-1.5 text-[13px] font-semibold">
177 − <Braces className="size-3.5" /> Response format
178 − </h4>
179 − <Row label="Mode">
180 − <Select value={settings.responseFormat?.type ?? "text"} onValueChange={(v) => (v === "text" ? unset("responseFormat") : set("responseFormat", { type: v as "json" | "json_schema", schema: settings.responseFormat?.schema, strict: true }))}>
181 − <SelectTrigger className="h-8 w-44 text-[13px]">
182 − <SelectValue />
183 − </SelectTrigger>
184 − <SelectContent>
185 − <SelectItem value="text">Text (default)</SelectItem>
186 − <SelectItem value="json">JSON object</SelectItem>
187 − <SelectItem value="json_schema">JSON schema</SelectItem>
188 − </SelectContent>
189 − </Select>
190 − </Row>
191 − {settings.responseFormat?.type === "json_schema" ? <SchemaEditor value={settings.responseFormat.schema} onChange={(schema) => set("responseFormat", { type: "json_schema", schema, strict: true })} /> : null}
192 − </section>
193 − ) : null}
245 + {unsupported.length ? <p className="px-1 text-[11.5px] text-fg-subtle">Not available for this model: {unsupported.join(", ")}.</p> : null}
246 + </div>
247 + </ResponsiveDialog>
194 248
195 − {c?.tools || c?.webSearch ? (
196 − <section className="space-y-3 rounded-lg border border-border p-3">
197 − <h4 className="text-[13px] font-semibold">Tools</h4>
198 − {c?.webSearch ? (
199 − <Row label={<span className="inline-flex items-center gap-1.5"><Globe className="size-3.5" /> Web search</span>} hint="Provider-native search with citations (billed by the provider)">
200 − <Switch checked={Boolean(settings.webSearch)} onCheckedChange={(v) => (v ? set("webSearch", true) : unset("webSearch"))} />
201 − </Row>
202 − ) : null}
203 − {c?.tools && model?.metadata?.codeExecution !== false && (model?.provider === "anthropic" || model?.provider === "openai" || model?.provider === "gemini") ? (
204 − <Row label={<span className="inline-flex items-center gap-1.5"><Terminal className="size-3.5" /> Code execution</span>} hint="Provider-hosted sandbox (not combinable with web search on Anthropic)">
205 − <Switch checked={Boolean(settings.codeExecution)} onCheckedChange={(v) => (v ? set("codeExecution", true) : unset("codeExecution"))} />
206 − </Row>
207 − ) : null}
208 − {c?.tools ? (
209 − <div className="space-y-2">
210 − <p className="text-xs text-fg-muted">Built-in PolyLLM tools (run server-side, side-effect free):</p>
211 − <div className="flex flex-wrap gap-1.5">
212 − {[
213 − { id: "calculator", label: "Calculator", icon: <Calculator className="size-3.5" /> },
214 − { id: "clock", label: "Clock", icon: <Clock className="size-3.5" /> },
215 − { id: "random", label: "Random", icon: <Dices className="size-3.5" /> },
216 − ].map((t) => {
217 − const on = settings.tools?.includes(t.id);
218 − return (
219 − <button key={t.id} onClick={() => set("tools", on ? (settings.tools ?? []).filter((x) => x !== t.id) : [...(settings.tools ?? []), t.id])} className={cn("inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[12.5px] transition-colors", on ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-border-strong")}>
220 − {t.icon} {t.label}
221 − </button>
222 − );
223 − })}
224 − </div>
225 − {settings.tools?.length ? (
226 − <Row label="Tool choice">
227 − <Select value={typeof settings.toolChoice === "string" ? settings.toolChoice : "auto"} onValueChange={(v) => set("toolChoice", v as "auto" | "none" | "required")}>
228 − <SelectTrigger className="h-8 w-36 text-[13px]">
229 − <SelectValue />
230 − </SelectTrigger>
231 − <SelectContent>
232 − <SelectItem value="auto">Auto</SelectItem>
233 − <SelectItem value="required">Required</SelectItem>
234 − <SelectItem value="none">None</SelectItem>
235 − </SelectContent>
236 − </Select>
237 − </Row>
238 − ) : null}
239 − </div>
240 − ) : null}
241 − </section>
242 − ) : null}
249 + {model ? <PresetPicker open={presetsOpen} onOpenChange={setPresetsOpen} model={model} onApply={applyPreset} /> : null}
250 + <PromptDialog open={saveOpen} onOpenChange={setSaveOpen} title="Save as preset" description="A model, its system prompt and these parameters, saved as one thing." label="Preset name" placeholder={model ? `${model.displayName} custom` : "My preset"} defaultValue={model ? `${model.displayName} custom` : ""} maxLength={80} onSubmit={savePreset} />
251 + </>
252 + );
253 +}
243 254
244 − {unsupported.length ? <p className="text-xs text-fg-subtle">Not available for this model: {unsupported.join(", ")}.</p> : null}
245 − </DialogBody>
246 − <DialogFooter className="sm:justify-between">
247 − <Button variant="ghost" size="sm" onClick={() => onChange({})}>
248 − <RotateCcw /> Reset to defaults
249 − </Button>
250 − <div className="flex gap-2">
251 − {allowSavePreset ? (
252 − <Button variant="outline" size="sm" onClick={savePreset} disabled={!model}>
253 − <Save /> Save as preset
254 − </Button>
255 − ) : null}
256 − <Button size="sm" onClick={() => setOpen(false)}>
257 − Done
258 − </Button>
259 − </div>
260 − </DialogFooter>
261 − </DialogContent>
262 − </Dialog>
255 +/* --------------------------------------------------------------------------------------------- */
256 +
257 +function PresetPicker({ open, onOpenChange, model, onApply }: { open: boolean; onOpenChange: (v: boolean) => void; model: PolyModel; onApply: (p: ModelPreset) => void }) {
258 + const { modelsByKey } = useApp();
259 + const q = useApi<{ modelPresets: ModelPreset[] }>(open ? "/api/presets" : null);
260 + const presets = React.useMemo(() => {
261 + const list = q.data?.modelPresets ?? [];
262 + return list
263 + .map((p) => ({ preset: p, compat: presetCompatibility(p, model) }))
264 + .sort((a, b) => Number(b.compat.compatible) - Number(a.compat.compatible) || Number(b.preset.modelKey === model.key) - Number(a.preset.modelKey === model.key) || a.preset.name.localeCompare(b.preset.name));
265 + }, [q.data, model]);
266 + return (
267 + <ResponsiveDialog open={open} onOpenChange={onOpenChange} title="Apply a preset" description={`Presets whose every parameter is supported by ${model.displayName} can be applied. Others explain why not.`} size="md" snap="full">
268 + {q.isLoading ? (
269 + <div className="space-y-2 pt-1">
270 + {Array.from({ length: 3 }).map((_, i) => (
271 + <Skeleton key={i} className="h-16" />
272 + ))}
273 + </div>
274 + ) : presets.length === 0 ? (
275 + <EmptyState className="mt-1" icon={<Bookmark />} title="No presets yet" description="Save your current settings as a preset from the configuration panel, or create one under Presets." />
276 + ) : (
277 + <ul className="space-y-2 pt-1">
278 + {presets.map(({ preset: p, compat }) => {
279 + const saved = modelsByKey.get(p.modelKey);
280 + return (
281 + <li key={p.id} className={cn("panel flex items-start gap-3 p-3", !compat.compatible && "opacity-80")}>
282 + <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-elevated text-lg leading-none" aria-hidden>
283 + {p.icon || <Bookmark className="size-4 text-fg-muted" />}
284 + </span>
285 + <div className="min-w-0 flex-1">
286 + <p className="truncate text-[14px] font-medium">{p.name}</p>
287 + <p className="mt-0.5 flex items-center gap-1.5 text-[12px] text-fg-muted">
288 + <ProviderIcon provider={saved?.provider ?? p.modelKey.split("/")[0]} size={11} />
289 + <span className="truncate">Saved for {saved?.displayName ?? p.modelKey}</span>
290 + <span className="text-fg-subtle">· {compat.total} setting{compat.total === 1 ? "" : "s"}</span>
291 + </p>
292 + {compat.compatible ? null : (
293 + <ul className="mt-1.5 space-y-0.5 text-[11.5px] text-warning">
294 + {compat.unsupported.map((u) => (
295 + <li key={u.key} className="flex items-start gap-1">
296 + <X className="mt-0.5 size-3 shrink-0" /> {u.reason}
297 + </li>
298 + ))}
299 + </ul>
300 + )}
301 + </div>
302 + <Button size="sm" variant={compat.compatible ? "primary" : "outline"} className="h-9 shrink-0 sm:h-8" disabled={!compat.compatible} onClick={() => onApply(p)}>
303 + Apply
304 + </Button>
305 + </li>
306 + );
307 + })}
308 + </ul>
309 + )}
310 + </ResponsiveDialog>
263 311 );
264 312 }
265 313
266 −function Row({ label, hint, children }: { label: React.ReactNode; hint?: string; children: React.ReactNode }) {
314 +/* --------------------------------------------------------------------------------------------- */
315 +
316 +function FieldRow({ label, hint, children }: { label: React.ReactNode; hint?: string; children: React.ReactNode }) {
267 317 return (
268 − <div className="flex items-center justify-between gap-4">
318 + <div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
269 319 <div className="min-w-0">
270 320 <div className="text-[13px] font-medium">{label}</div>
271 321 {hint ? <div className="text-[11.5px] text-fg-subtle">{hint}</div> : null}
272 322 </div>
273 − <div className="shrink-0">{children}</div>
323 + <div className="sm:shrink-0">{children}</div>
274 324 </div>
275 325 );
276 326 }
277 327
278 −function SliderRow({ label, value, min, max, step, onChange }: { label: string; value: number | undefined; min: number; max: number; step: number; onChange: (v: number | undefined) => void }) {
328 +function SwitchRow({ label, hint, icon, checked, onChange }: { label: string; hint?: string; icon?: React.ReactNode; checked: boolean; onChange: (v: boolean) => void }) {
329 + const id = React.useId();
330 + return (
331 + <div className="flex items-center justify-between gap-4">
332 + <label htmlFor={id} className="min-w-0 cursor-pointer">
333 + <span className="flex items-center gap-1.5 text-[13px] font-medium [&_svg]:size-3.5">
334 + {icon} {label}
335 + </span>
336 + {hint ? <span className="block text-[11.5px] text-fg-subtle">{hint}</span> : null}
337 + </label>
338 + <Switch id={id} checked={checked} onCheckedChange={onChange} />
339 + </div>
340 + );
341 +}
342 +
343 +/** Slider with an inline numeric input; "Default" means unset (provider default). */
344 +function SliderRow({ label, hint, value, min, max, step, onChange }: { label: string; hint?: string; value: number | undefined; min: number; max: number; step: number; onChange: (v: number | undefined) => void }) {
345 + const id = React.useId();
279 346 const on = value !== undefined;
347 + const decimals = step >= 1 ? 0 : step >= 0.1 ? 1 : 2;
348 + const shown = on ? value! : (min + max) / 2;
280 349 return (
281 350 <div className="space-y-1">
282 − <div className="flex items-center justify-between">
283 − <span className="text-[13px] font-medium">{label}</span>
284 − <div className="flex items-center gap-2">
285 − <span className="w-12 text-right font-mono text-[12px] tabular-nums text-fg-muted">{on ? value!.toFixed(step < 0.1 ? 2 : 1) : "default"}</span>
286 − <Switch size="sm" checked={on} onCheckedChange={(v) => onChange(v ? (min + max) / 2 : undefined)} aria-label={`Enable ${label}`} />
351 + <div className="flex items-center justify-between gap-3">
352 + <Label htmlFor={id} className="text-fg">
353 + {label}
354 + </Label>
355 + <div className="flex items-center gap-1.5">
356 + <Input
357 + id={id}
358 + type="number"
359 + inputMode="decimal"
360 + className="h-9 w-24 text-right font-mono text-[15px] tabular-nums sm:h-7 sm:w-20 sm:text-[12.5px]"
361 + value={on ? value : ""}
362 + placeholder="default"
363 + min={min}
364 + max={max}
365 + step={step}
366 + onChange={(e) => {
367 + const raw = e.target.value;
368 + if (raw === "") return onChange(undefined);
369 + const n = Number(raw);
370 + if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, n)));
371 + }}
372 + />
373 + {on ? (
374 + <button type="button" onClick={() => onChange(undefined)} className="tap rounded p-1 text-fg-subtle hover:text-fg" aria-label={`Reset ${label} to default`}>
375 + <X className="size-3.5" />
376 + </button>
377 + ) : (
378 + <span className="w-[22px]" />
379 + )}
287 380 </div>
288 381 </div>
289 − <Slider value={[on ? value! : (min + max) / 2]} min={min} max={max} step={step} onValueChange={([v]) => onChange(v)} disabled={!on} className={cn(!on && "opacity-40")} />
382 + <Slider value={[shown]} min={min} max={max} step={step} onValueChange={([v]) => onChange(Number(v.toFixed(decimals)))} className={cn("py-2.5 sm:py-2", !on && "opacity-50")} aria-label={label} />
383 + <div className="flex justify-between text-[10.5px] text-fg-subtle">
384 + <span>{min}</span>
385 + {hint ? <span className="truncate px-2">{hint}</span> : null}
386 + <span>{max}</span>
387 + </div>
290 388 </div>
291 389 );
292 390 }
293 391
294 −function NumberInput({ value, onChange, min, max, placeholder }: { value: number | undefined; onChange: (v: number | undefined) => void; min: number; max: number; placeholder?: string }) {
392 +function NumberRow({ label, hint, value, min, max, placeholder, onChange }: { label: string; hint?: string; value: number | undefined; min: number; max: number; placeholder?: string; onChange: (v: number | undefined) => void }) {
393 + return (
394 + <FieldRow label={label} hint={hint}>
395 + <Input
396 + type="number"
397 + inputMode="numeric"
398 + className="h-10 font-mono tabular-nums sm:h-8 sm:w-36 sm:text-[13px]"
399 + value={value ?? ""}
400 + min={min}
401 + max={max}
402 + placeholder={placeholder}
403 + onChange={(e) => {
404 + const v = e.target.value;
405 + if (v === "") return onChange(undefined);
406 + const n = Number(v);
407 + if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, Math.floor(n))));
408 + }}
409 + />
410 + </FieldRow>
411 + );
412 +}
413 +
414 +/** Segmented control for ≤ 4 options (plus Default), Select otherwise. */
415 +function ChoiceRow({ label, hint, value, options, onChange, noDefault }: { label: string; hint?: string; value: string | undefined; options: { value: string; label: string }[]; onChange: (v: string | undefined) => void; noDefault?: boolean }) {
416 + const all = noDefault ? options : [{ value: UNSET, label: "Default" }, ...options];
417 + const current = value ?? (noDefault ? options[0]?.value : UNSET);
295 418 return (
296 − <Input
297 − type="number"
298 − className="h-8 w-32 text-[13px] tabular-nums"
299 − value={value ?? ""}
300 − min={min}
301 − max={max}
302 − placeholder={placeholder}
303 − onChange={(e) => {
304 − const v = e.target.value;
305 − if (v === "") return onChange(undefined);
306 − const n = Number(v);
307 − if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, Math.floor(n))));
308 − }}
309 − />
419 + <FieldRow label={label} hint={hint}>
420 + {all.length <= 5 ? (
421 + <Segmented size="sm" ariaLabel={label} value={current} onChange={(v) => onChange(v === UNSET ? undefined : v)} options={all} className="w-full sm:w-auto" fill />
422 + ) : (
423 + <Select value={current} onValueChange={(v) => onChange(v === UNSET ? undefined : v)}>
424 + <SelectTrigger className="h-10 w-full sm:h-8 sm:w-48 sm:text-[13px]" aria-label={label}>
425 + <SelectValue />
426 + </SelectTrigger>
427 + <SelectContent>
428 + {all.map((o) => (
429 + <SelectItem key={o.value} value={o.value}>
430 + {o.label}
431 + </SelectItem>
432 + ))}
433 + </SelectContent>
434 + </Select>
435 + )}
436 + </FieldRow>
310 437 );
311 438 }
312 439
added src/components/chat/model-launcher.tsx +36 −0
@@ -0,0 +1,36 @@
1 +"use client";
2 +import * as React from "react";
3 +import { ModelSelector } from "./model-selector";
4 +import { Button, type ButtonProps } from "@/components/ui/button";
5 +import { cn } from "@/lib/utils";
6 +
7 +/**
8 + * Opens the shared `ModelSelector` picker from any control (menu item, error card, router card…)
9 + * while only relying on its public props: the real trigger is rendered visually hidden and clicked.
10 + *
11 + * TODO(integration: B) — when `ModelSelector` exposes an `open`/`onOpenChange` prop, drop the hidden trigger.
12 + */
13 +export interface ModelPickerLauncherHandle {
14 + open(): void;
15 +}
16 +
17 +export const ModelPickerLauncher = React.forwardRef<ModelPickerLauncherHandle, { value?: string | null; onChange: (key: string) => void; label?: React.ReactNode; className?: string; variant?: ButtonProps["variant"]; size?: ButtonProps["size"]; icon?: React.ReactNode; hiddenTrigger?: boolean }>(function ModelPickerLauncher({ value = null, onChange, label, className, variant = "outline", size = "md", icon, hiddenTrigger }, ref) {
18 + const holder = React.useRef<HTMLSpanElement>(null);
19 + const open = React.useCallback(() => {
20 + holder.current?.querySelector("button")?.click();
21 + }, []);
22 + React.useImperativeHandle(ref, () => ({ open }), [open]);
23 + return (
24 + <>
25 + <span ref={holder} className="hidden" aria-hidden>
26 + <ModelSelector value={value} onChange={onChange} size="sm" />
27 + </span>
28 + {hiddenTrigger ? null : (
29 + <Button type="button" variant={variant} size={size} className={cn(className)} onClick={open}>
30 + {icon}
31 + {label ?? "Choose a model"}
32 + </Button>
33 + )}
34 + </>
35 + );
36 +});
modified src/components/chat/model-selector.tsx +516 −189
@@ -1,39 +1,77 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { Brain, ChevronDown, Clock, Eye, Maximize2, Search, Star, Wrench, Zap, KeyRound } from "lucide-react";
4 3 import Link from "next/link";
5 −import { useApp } from "@/components/app/store";
4 +import { useRouter } from "next/navigation";
5 +import { useVirtualizer } from "@tanstack/react-virtual";
6 +import { Check, ChevronDown, ChevronRight, GitCompareArrows, Info, KeyRound, MoreHorizontal, Search, Sparkles, Star, Tag, X } from "lucide-react";
7 +import { AUTO_MODEL_KEY, useApp } from "@/components/app/store";
6 8 import type { PolyModel, ProviderId } from "@/lib/client/types";
7 9 import { PROVIDERS, PROVIDER_ORDER } from "@/lib/client/providers";
8 10 import { ProviderIcon } from "@/components/brand/provider-icon";
9 11 import { Button } from "@/components/ui/button";
10 −import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
11 12 import { Badge } from "@/components/ui/badge";
12 13 import { Kbd } from "@/components/ui/misc";
13 −import { CapabilityBadges, PriceLabel, StatusBadge, isFastModel } from "./model-badges";
14 +import { Segmented } from "@/components/ui/segmented";
15 +import { ActionSheet, ResponsiveDialog, type ActionSheetItem } from "@/components/ui/sheet";
16 +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
17 +import { PromptDialog } from "@/components/common/prompt-dialog";
18 +import { ModelProfile } from "@/components/models/model-profile";
19 +import { CapabilityGlyphs, ModelBadges, StatusBadge, useBadgeContext } from "./model-badges";
20 +import { useDebounced, useIsMobile, useLocalStorage, useLongPress } from "@/lib/client/hooks";
21 +import { ROUTER_MODES, explainRoute, type RouterMode } from "@/lib/client/router";
22 +import { buildSelectorSections, recommendedModels, type Section } from "@/lib/models/sections";
23 +import { parseSearchQuery, searchModels, isEmptyIntent } from "@/lib/models/search";
24 +import { sortWeightOf, type BadgeContext } from "@/lib/models/badges";
25 +import { formatPrice } from "@/lib/models/format";
14 26 import { cn, formatTokens } from "@/lib/utils";
15 27
16 −type Category = "all" | "favorites" | "recent" | ProviderId | "reasoning" | "fast" | "vision" | "long" | "tools";
17 −
18 −const CATEGORIES: { key: Category; label: string; icon?: React.ReactNode }[] = [
19 − { key: "all", label: "All" },
20 − { key: "favorites", label: "Favorites", icon: <Star className="size-3.5" /> },
21 − { key: "recent", label: "Recent", icon: <Clock className="size-3.5" /> },
22 − ...PROVIDER_ORDER.map((p) => ({ key: p as Category, label: PROVIDERS[p].shortName, icon: <ProviderIcon provider={p} size={14} /> })),
23 − { key: "reasoning", label: "Reasoning", icon: <Brain className="size-3.5" /> },
24 − { key: "fast", label: "Fast", icon: <Zap className="size-3.5" /> },
25 − { key: "vision", label: "Vision", icon: <Eye className="size-3.5" /> },
26 − { key: "long", label: "Long context", icon: <Maximize2 className="size-3.5" /> },
27 − { key: "tools", label: "Tools", icon: <Wrench className="size-3.5" /> },
28 −];
29 −
30 −export function ModelSelector({ value, onChange, className, size = "md", allowDisconnected = false, multiple, selected, onToggle, buttonLabel }: { value: string | null; onChange?: (key: string) => void; className?: string; size?: "sm" | "md"; allowDisconnected?: boolean; multiple?: boolean; selected?: Set<string>; onToggle?: (key: string) => void; buttonLabel?: string }) {
31 − const { models, modelsByKey, favorites, recents, connectedProviders, toggleFavorite, loadingModels } = useApp();
28 +export const ROUTER_MODE_KEY = "polyllm:router-mode";
29 +
30 +export interface ModelSelectorProps {
31 + value: string | null;
32 + onChange?: (key: string) => void;
33 + className?: string;
34 + size?: "sm" | "md";
35 + /** List models whose provider has no key (rendered as Unavailable). */
36 + allowDisconnected?: boolean;
37 + multiple?: boolean;
38 + selected?: Set<string>;
39 + onToggle?: (key: string) => void;
40 + buttonLabel?: string;
41 + /** Show the Smart Router "Auto" entry (selects `AUTO_MODEL_KEY`). */
42 + showAuto?: boolean;
43 + /** Custom trigger element (wrapped; click opens the picker). */
44 + trigger?: React.ReactNode;
45 + /** Current composer draft — powers the "Recommended" section. */
46 + draft?: string;
47 +}
48 +
49 +type Item =
50 + | { type: "auto" }
51 + | { type: "header"; key: string; label: string; hint?: string; total: number; shown: number; expandable: boolean; expanded: boolean }
52 + | { type: "row"; model: PolyModel; section: string; why?: string[] }
53 + | { type: "provider"; provider: ProviderId; count: number };
54 +
55 +const ROW_H = 56;
56 +
57 +export function ModelSelector({ value, onChange, className, size = "md", allowDisconnected = false, multiple, selected, onToggle, buttonLabel, showAuto = false, trigger, draft }: ModelSelectorProps) {
58 + const { models, modelsByKey, favorites, recents, labels, connectedProviders, toggleFavorite, setLabel, loadingModels } = useApp();
59 + const isMobile = useIsMobile();
60 + const router = useRouter();
61 + const ctx = useBadgeContext();
32 62 const [open, setOpen] = React.useState(false);
33 63 const [q, setQ] = React.useState("");
34 − const [cat, setCat] = React.useState<Category>("all");
64 + const dq = useDebounced(q, 60);
65 + const [mode, setMode] = useLocalStorage<RouterMode>(ROUTER_MODE_KEY, "balanced");
66 + const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set());
67 + const [active, setActive] = React.useState(0);
68 + const [menuModel, setMenuModel] = React.useState<PolyModel | null>(null);
69 + const [labelModel, setLabelModel] = React.useState<PolyModel | null>(null);
70 + const [profileModel, setProfileModel] = React.useState<PolyModel | null>(null);
35 71 const inputRef = React.useRef<HTMLInputElement>(null);
36 − const current = value ? modelsByKey.get(value) : undefined;
72 + const scrollRef = React.useRef<HTMLDivElement>(null);
73 + const current = value && value !== AUTO_MODEL_KEY ? modelsByKey.get(value) : undefined;
74 + const isAuto = value === AUTO_MODEL_KEY;
37 75
38 76 React.useEffect(() => {
39 77 const onKey = (e: KeyboardEvent) => {
@@ -47,190 +85,300 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi
47 85 }, []);
48 86
49 87 React.useEffect(() => {
50 − if (open) setTimeout(() => inputRef.current?.focus(), 30);
51 − else {
52 − // eslint-disable-next-line react-hooks/set-state-in-effect
88 + if (open) {
89 + if (!isMobile) setTimeout(() => inputRef.current?.focus(), 40);
90 + } else {
53 91 setQ("");
92 + setExpanded(new Set());
54 93 }
55 − }, [open]);
94 + }, [open, isMobile]);
56 95
57 96 const usable = React.useMemo(() => models.filter((m) => (allowDisconnected || connectedProviders.has(m.provider)) && m.status !== "deprecated"), [models, connectedProviders, allowDisconnected]);
58 97
59 − const filtered = React.useMemo(() => {
60 − const term = q.trim().toLowerCase();
61 − let list = usable;
62 − switch (cat) {
63 − case "favorites":
64 − list = list.filter((m) => favorites.has(m.key));
65 − break;
66 − case "recent":
67 − list = recents.map((k) => modelsByKey.get(k)).filter((m): m is PolyModel => Boolean(m) && usable.includes(m as PolyModel));
68 − break;
69 − case "reasoning":
70 − list = list.filter((m) => m.capabilities.reasoning);
71 − break;
72 − case "fast":
73 − list = list.filter(isFastModel);
74 − break;
75 − case "vision":
76 − list = list.filter((m) => m.capabilities.vision);
77 − break;
78 − case "long":
79 − list = list.filter((m) => (m.limits?.contextTokens ?? 0) >= 400_000);
80 − break;
81 − case "tools":
82 − list = list.filter((m) => m.capabilities.tools);
83 − break;
84 − case "all":
85 − break;
86 − default:
87 − list = list.filter((m) => m.provider === cat);
98 + const intent = React.useMemo(() => parseSearchQuery(dq), [dq]);
99 + const searching = dq.trim().length > 0 && !isEmptyIntent(intent);
100 +
101 + const results = React.useMemo(() => (searching ? searchModels(usable, intent, { ctx, favorites, recents, labels, providerNames: Object.fromEntries(PROVIDER_ORDER.map((p) => [p, PROVIDERS[p].name])) }) : null), [searching, usable, intent, ctx, favorites, recents, labels]);
102 +
103 + const sections = React.useMemo<Section[]>(() => (searching ? [] : buildSelectorSections(usable, { favorites, recents, ctx, mode, draft, perSection: 5 })), [searching, usable, favorites, recents, ctx, mode, draft]);
104 +
105 + const autoPick = React.useMemo(() => (showAuto && !multiple ? recommendedModels(usable, { favorites, mode, draft, limit: 1 }) : null), [showAuto, multiple, usable, favorites, mode, draft]);
106 +
107 + const items = React.useMemo<Item[]>(() => {
108 + const out: Item[] = [];
109 + if (searching && results) {
110 + out.push({ type: "header", key: "results", label: results.length ? `${results.length} result${results.length === 1 ? "" : "s"}` : "No matches", total: results.length, shown: results.length, expandable: false, expanded: true });
111 + for (const r of results) out.push({ type: "row", model: r.model, section: "results", why: r.matches });
112 + return out;
88 113 }
89 − if (term) {
90 − const words = term.split(/\s+/);
91 − list = list.filter((m) => {
92 − const hay = `${m.displayName} ${m.id} ${m.provider} ${PROVIDERS[m.provider].name} ${m.family ?? ""} ${m.capabilities.reasoning ? "reasoning thinking" : ""} ${m.capabilities.vision ? "vision image" : ""} ${(m.limits?.contextTokens ?? 0) >= 400_000 ? "long context" : ""} ${m.capabilities.tools ? "tools functions" : ""} ${m.capabilities.webSearch ? "web search" : ""}`.toLowerCase();
93 − return words.every((w) => hay.includes(w));
94 − });
114 + if (showAuto && !multiple) out.push({ type: "auto" });
115 + for (const s of sections) {
116 + const isOpen = expanded.has(s.key);
117 + const list = isOpen ? s.all : s.items;
118 + out.push({ type: "header", key: s.key, label: s.label, hint: s.hint, total: s.total, shown: list.length, expandable: s.total > s.items.length, expanded: isOpen });
119 + for (const m of list) out.push({ type: "row", model: m, section: s.key });
95 120 }
96 − return list;
97 − }, [usable, cat, q, favorites, recents, modelsByKey]);
98 −
99 − const grouped = React.useMemo(() => {
100 − if (cat !== "all" || q) return [{ provider: null as ProviderId | null, items: filtered }];
101 − return PROVIDER_ORDER.map((p) => ({ provider: p, items: filtered.filter((m) => m.provider === p) })).filter((g) => g.items.length);
102 − }, [filtered, cat, q]);
103 −
104 − const pick = (m: PolyModel) => {
105 − if (multiple && onToggle) {
106 − onToggle(m.key);
107 − return;
121 + // Everything, grouped by provider
122 + const byProvider = PROVIDER_ORDER.map((p) => ({ p, list: usable.filter((m) => m.provider === p).sort((a, b) => sortWeightOf(b) - sortWeightOf(a) || a.displayName.localeCompare(b.displayName)) })).filter((g) => g.list.length);
123 + if (byProvider.length) {
124 + out.push({ type: "header", key: "all", label: "All models", hint: `${usable.length} across ${byProvider.length} provider${byProvider.length === 1 ? "" : "s"}`, total: usable.length, shown: usable.length, expandable: false, expanded: true });
125 + for (const g of byProvider) {
126 + out.push({ type: "provider", provider: g.p, count: g.list.length });
127 + for (const m of g.list) out.push({ type: "row", model: m, section: `all:${g.p}` });
128 + }
108 129 }
109 − onChange?.(m.key);
130 + return out;
131 + }, [searching, results, showAuto, multiple, sections, expanded, usable]);
132 +
133 + const selectable = React.useMemo(() => items.map((it, i) => (it.type === "row" || it.type === "auto" ? i : -1)).filter((i) => i >= 0), [items]);
134 +
135 + // Reset the keyboard cursor when the list changes.
136 + React.useEffect(() => {
137 + setActive(selectable[0] ?? 0);
138 + }, [selectable]);
139 +
140 + const virtualizer = useVirtualizer({
141 + count: items.length,
142 + getScrollElement: () => scrollRef.current,
143 + estimateSize: (i) => {
144 + const it = items[i];
145 + return it.type === "row" ? ROW_H : it.type === "auto" ? 168 : it.type === "provider" ? 32 : 40;
146 + },
147 + overscan: 10,
148 + getItemKey: (i) => {
149 + const it = items[i];
150 + return it.type === "row" ? `${it.section}:${it.model.key}` : it.type === "header" ? `h:${it.key}` : it.type === "provider" ? `p:${it.provider}` : "auto";
151 + },
152 + });
153 +
154 + const pick = React.useCallback(
155 + (m: PolyModel) => {
156 + if (!allowDisconnected && !connectedProviders.has(m.provider)) return;
157 + if (multiple && onToggle) {
158 + onToggle(m.key);
159 + return;
160 + }
161 + onChange?.(m.key);
162 + setOpen(false);
163 + },
164 + [allowDisconnected, connectedProviders, multiple, onToggle, onChange],
165 + );
166 +
167 + const pickAuto = () => {
168 + onChange?.(AUTO_MODEL_KEY);
110 169 setOpen(false);
111 170 };
112 171
113 − const noProviders = connectedProviders.size === 0 && !loadingModels;
172 + const moveActive = (dir: 1 | -1) => {
173 + if (!selectable.length) return;
174 + const pos = selectable.indexOf(active);
175 + const next = selectable[Math.max(0, Math.min(selectable.length - 1, (pos < 0 ? (dir > 0 ? -1 : 0) : pos) + dir))];
176 + setActive(next);
177 + virtualizer.scrollToIndex(next, { align: "auto" });
178 + };
114 179
115 − return (
180 + const onKeyNav = (e: React.KeyboardEvent) => {
181 + if (e.key === "ArrowDown") {
182 + e.preventDefault();
183 + moveActive(1);
184 + } else if (e.key === "ArrowUp") {
185 + e.preventDefault();
186 + moveActive(-1);
187 + } else if (e.key === "Enter") {
188 + const it = items[active];
189 + if (!it) return;
190 + e.preventDefault();
191 + if (it.type === "row") pick(it.model);
192 + else if (it.type === "auto") pickAuto();
193 + }
194 + };
195 +
196 + const openCompare = (m: PolyModel) => {
197 + const keys = [...new Set([current?.key, m.key].filter((k): k is string => Boolean(k)))];
198 + setOpen(false);
199 + router.push(`/app/models/compare?m=${encodeURIComponent(keys.join(","))}`);
200 + };
201 +
202 + const menuItems = (m: PolyModel): ActionSheetItem[] => {
203 + const fav = favorites.has(m.key);
204 + const items: ActionSheetItem[] = [
205 + { key: "fav", label: fav ? "Remove from favorites" : "Add to favorites", icon: <Star className={cn(fav && "fill-current text-warning")} />, onSelect: () => void toggleFavorite(m.key) },
206 + { key: "label", label: labels[m.key] ? "Change custom label" : "Set custom label", icon: <Tag />, hint: labels[m.key], onSelect: () => setLabelModel(m) },
207 + ];
208 + if (labels[m.key]) items.push({ key: "unlabel", label: "Remove custom label", icon: <X />, onSelect: () => void setLabel(m.key, null) });
209 + items.push({ key: "profile", label: "View profile", icon: <Info />, onSelect: () => setProfileModel(m) }, { key: "compare", label: current && current.key !== m.key ? `Compare with ${current.displayName}` : "Compare", icon: <GitCompareArrows />, onSelect: () => openCompare(m) });
210 + return items;
211 + };
212 +
213 + const toggleExpanded = (key: string) =>
214 + setExpanded((prev) => {
215 + const n = new Set(prev);
216 + if (n.has(key)) n.delete(key);
217 + else n.add(key);
218 + return n;
219 + });
220 +
221 + const noProviders = connectedProviders.size === 0 && !loadingModels;
222 + const triggerLabel = buttonLabel ? (
223 + <span className="truncate font-medium">{buttonLabel}</span>
224 + ) : isAuto ? (
116 225 <>
117 − <button
118 − type="button"
119 − onClick={() => setOpen(true)}
120 − className={cn(
121 − "inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-bg-elevated text-left transition-colors hover:border-border-strong hover:bg-bg-subtle",
122 − size === "sm" ? "h-8 px-2.5 text-[13px]" : "h-9 px-3 text-sm",
123 − className,
124 − )}
125 − aria-label="Select model"
126 − title="Select model (⌘/)"
127 − >
128 − {buttonLabel ? (
129 − <span className="truncate font-medium">{buttonLabel}</span>
130 − ) : current ? (
131 − <>
132 − <ProviderIcon provider={current.provider} size={15} />
133 − <span className="truncate font-medium">{current.displayName}</span>
134 − {current.capabilities.reasoning ? <Brain className="size-3.5 text-fg-subtle" /> : null}
135 − </>
226 + <Sparkles className="size-3.5 shrink-0 text-accent" />
227 + <span className="truncate font-medium">Auto</span>
228 + <span className="hidden truncate text-fg-subtle sm:inline">· {ROUTER_MODES.find((r) => r.value === mode)?.label}</span>
229 + </>
230 + ) : current ? (
231 + <>
232 + <ProviderIcon provider={current.provider} size={15} />
233 + <span className="truncate font-medium">{labels[current.key] ?? current.displayName}</span>
234 + {labels[current.key] ? <span className="hidden truncate text-fg-subtle sm:inline">{current.displayName}</span> : null}
235 + </>
236 + ) : (
237 + <span className="truncate text-fg-muted">{loadingModels ? "Loading models…" : noProviders ? "Connect a provider" : "Choose a model"}</span>
238 + );
239 +
240 + const header = (
241 + <div className="border-b border-border">
242 + <div className="flex items-center gap-2 px-3">
243 + <Search className="size-4 shrink-0 text-fg-subtle" />
244 + <input
245 + ref={inputRef}
246 + value={q}
247 + onChange={(e) => setQ(e.target.value)}
248 + onKeyDown={onKeyNav}
249 + placeholder={isMobile ? "Search models…" : "Search — try “cheap vision”, “1M context”, “under $1/M”, “fastest gemini”"}
250 + className="h-12 min-w-0 flex-1 bg-transparent text-[16px] outline-none placeholder:text-fg-subtle sm:text-[15px]"
251 + aria-label="Search models"
252 + role="combobox"
253 + aria-expanded
254 + aria-controls="model-selector-list"
255 + aria-activedescendant={items[active] ? `model-opt-${active}` : undefined}
256 + autoComplete="off"
257 + enterKeyHint="done"
258 + />
259 + {q ? (
260 + <button type="button" onClick={() => setQ("")} className="tap rounded-full bg-bg-muted p-1 text-fg-muted" aria-label="Clear search">
261 + <X className="size-3.5" />
262 + </button>
136 263 ) : (
137 − <span className="truncate text-fg-muted">{loadingModels ? "Loading models…" : noProviders ? "Connect a provider" : "Choose a model"}</span>
264 + <Kbd className="hidden sm:inline-flex">esc</Kbd>
138 265 )}
139 − <ChevronDown className="size-3.5 shrink-0 text-fg-subtle" />
140 − </button>
141 −
142 − <Dialog open={open} onOpenChange={setOpen}>
143 − <DialogContent size="xl" hideClose className="p-0 overflow-hidden sm:max-h-[78vh] sm:h-[620px]">
144 − <DialogTitle className="sr-only">Select a model</DialogTitle>
145 − <div className="flex items-center gap-2 border-b border-border px-3">
146 − <Search className="size-4 text-fg-subtle" />
147 − <input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, provider, capability… (e.g. “vision long context”)" className="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-fg-subtle" aria-label="Search models" />
148 − <Kbd>esc</Kbd>
149 − </div>
150 − <div className="flex min-h-0 flex-1 flex-col sm:flex-row">
151 − <nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border p-2 scrollbar-none sm:w-44 sm:flex-col sm:overflow-y-auto sm:border-b-0 sm:border-r">
152 − {CATEGORIES.map((c) => {
153 − const count = c.key === "all" ? usable.length : c.key === "favorites" ? usable.filter((m) => favorites.has(m.key)).length : c.key === "recent" ? recents.filter((k) => modelsByKey.has(k)).length : undefined;
154 − const disabled = PROVIDER_ORDER.includes(c.key as ProviderId) && !connectedProviders.has(c.key as ProviderId) && !allowDisconnected;
155 − return (
156 − <button key={c.key} onClick={() => setCat(c.key)} disabled={disabled} className={cn("flex shrink-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] whitespace-nowrap transition-colors disabled:opacity-40", cat === c.key ? "bg-bg-muted text-fg font-medium" : "text-fg-muted hover:bg-bg-muted/60 hover:text-fg")}>
157 − {c.icon}
158 − {c.label}
159 − {count !== undefined ? <span className="ml-auto text-[11px] text-fg-subtle">{count}</span> : null}
160 − </button>
161 − );
162 − })}
163 − </nav>
164 − <div className="min-h-0 flex-1 overflow-y-auto p-2 scrollbar-thin">
165 − {noProviders ? (
166 − <div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
167 − <KeyRound className="size-6 text-fg-subtle" />
168 − <p className="text-sm font-medium">No provider connected yet</p>
169 − <p className="max-w-sm text-[13px] text-fg-muted">Add an API key from any supported provider (OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter, Cerebras) to unlock their models. Keys are encrypted and never leave the server.</p>
170 − <Button asChild variant="accent" size="sm">
171 − <Link href="/app/settings/providers">Connect a provider</Link>
172 − </Button>
173 − </div>
174 − ) : filtered.length === 0 ? (
175 − <p className="p-8 text-center text-sm text-fg-muted">No models match.</p>
176 − ) : (
177 − grouped.map((g) => (
178 − <div key={g.provider ?? "all"} className="mb-2">
179 − {g.provider ? (
180 − <div className="flex items-center gap-2 px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">
181 − <ProviderIcon provider={g.provider} size={13} /> {PROVIDERS[g.provider].name}
182 − </div>
183 − ) : null}
184 − <div className="grid gap-1 sm:grid-cols-2">
185 − {g.items.map((m) => {
186 − const isSel = multiple ? selected?.has(m.key) : m.key === value;
187 − return (
188 − <div key={m.key} className={cn("group relative flex cursor-pointer flex-col gap-1.5 rounded-lg border p-2.5 transition-colors", isSel ? "border-accent bg-accent-soft/60" : "border-transparent hover:border-border hover:bg-bg-subtle")} onClick={() => pick(m)} role="option" aria-selected={isSel} tabIndex={0} onKeyDown={(e) => (e.key === "Enter" ? pick(m) : null)}>
189 − <div className="flex items-start gap-2">
190 − <ProviderIcon provider={m.provider} size={16} className="mt-0.5" />
191 − <div className="min-w-0 flex-1">
192 − <div className="flex items-center gap-1.5">
193 − <span className="truncate text-[13.5px] font-medium">{m.displayName}</span>
194 − <StatusBadge status={m.status} shutdownDate={m.metadata?.shutdownDate as string | undefined} />
195 − </div>
196 − <div className="truncate font-mono text-[11px] text-fg-subtle">{m.id}</div>
197 − </div>
198 − <button
199 − onClick={(e) => {
200 − e.stopPropagation();
201 − void toggleFavorite(m.key);
202 − }}
203 − className={cn("rounded p-1 text-fg-subtle hover:text-warning", favorites.has(m.key) ? "text-warning" : "opacity-0 group-hover:opacity-100 focus:opacity-100")}
204 − aria-label={favorites.has(m.key) ? "Remove from favorites" : "Add to favorites"}
205 − >
206 − <Star className={cn("size-3.5", favorites.has(m.key) && "fill-current")} />
207 − </button>
208 − </div>
209 − <div className="flex flex-wrap items-center justify-between gap-1 text-[11px]">
210 − <CapabilityBadges model={m} compact max={5} />
211 − <span className="ml-auto flex items-center gap-2 text-fg-subtle tabular-nums">
212 − {m.limits?.contextTokens ? <span title="Context window">{formatTokens(m.limits.contextTokens)} ctx</span> : null}
213 − <PriceLabel model={m} />
214 − </span>
215 − </div>
216 − </div>
217 − );
218 − })}
219 − </div>
220 − </div>
221 − ))
222 − )}
266 + <button type="button" onClick={() => setOpen(false)} className="tap ml-1 rounded-md p-1.5 text-fg-subtle hover:bg-bg-muted hover:text-fg sm:hidden" aria-label="Close">
267 + <X className="size-4" />
268 + </button>
269 + </div>
270 + {searching && intent.chips.length ? (
271 + <div className="flex flex-wrap items-center gap-1 px-3 pb-2">
272 + <span className="text-[11px] text-fg-subtle">Understood:</span>
273 + {intent.chips.map((c) => (
274 + <Badge key={c} variant="accent">
275 + {c}
276 + </Badge>
277 + ))}
278 + </div>
279 + ) : null}
280 + </div>
281 + );
282 +
283 + const renderItem = (it: Item, index: number) => {
284 + switch (it.type) {
285 + case "auto":
286 + return <AutoCard selected={isAuto} active={active === index} mode={mode} onMode={setMode} onPick={pickAuto} pick={autoPick} id={`model-opt-${index}`} />;
287 + case "header":
288 + return (
289 + <div className={cn("flex items-end justify-between gap-2 px-3 pt-3 pb-1", it.key === "results" && "pt-2")}>
290 + <div className="min-w-0">
291 + <span className="text-[11px] font-semibold uppercase tracking-wide text-fg-subtle">{it.label}</span>
292 + {it.hint ? <span className="ml-2 hidden text-[11px] text-fg-subtle sm:inline">{it.hint}</span> : null}
223 293 </div>
294 + {it.expandable ? (
295 + <button type="button" onClick={() => toggleExpanded(it.key)} className="tap inline-flex items-center gap-0.5 text-[11.5px] font-medium text-accent">
296 + {it.expanded ? "Show less" : `Show all ${it.total}`}
297 + <ChevronRight className={cn("size-3 transition-transform", it.expanded && "rotate-90")} />
298 + </button>
299 + ) : null}
224 300 </div>
225 − {multiple ? (
226 − <div className="flex items-center justify-between border-t border-border px-3 py-2 text-[12px] text-fg-muted">
301 + );
302 + case "provider":
303 + return (
304 + <div className="flex items-center gap-1.5 px-3 pt-2 pb-0.5 text-[11px] font-medium text-fg-muted">
305 + <ProviderIcon provider={it.provider} size={12} /> {PROVIDERS[it.provider].name}
306 + <span className="text-fg-subtle">· {it.count}</span>
307 + </div>
308 + );
309 + case "row": {
310 + const m = it.model;
311 + return (
312 + <SelectorRow
313 + id={`model-opt-${index}`}
314 + model={m}
315 + ctx={ctx}
316 + label={labels[m.key]}
317 + fav={favorites.has(m.key)}
318 + connected={connectedProviders.has(m.provider)}
319 + selected={multiple ? Boolean(selected?.has(m.key)) : m.key === value}
320 + active={active === index}
321 + multiple={Boolean(multiple)}
322 + isMobile={isMobile}
323 + why={it.why}
324 + onPick={() => pick(m)}
325 + onFav={() => void toggleFavorite(m.key)}
326 + onMenu={() => setMenuModel(m)}
327 + onHover={() => setActive(index)}
328 + menuItems={() => menuItems(m)}
329 + />
330 + );
331 + }
332 + }
333 + };
334 +
335 + return (
336 + <>
337 + {trigger ? (
338 + <span className="contents" onClick={() => setOpen(true)}>
339 + {trigger}
340 + </span>
341 + ) : (
342 + <button
343 + type="button"
344 + onClick={() => setOpen(true)}
345 + className={cn(
346 + "inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-bg-elevated text-left transition-colors hover:border-border-strong hover:bg-bg-subtle",
347 + size === "sm" ? "h-8 px-2.5 text-[13px]" : "h-9 px-3 text-sm",
348 + className,
349 + )}
350 + aria-label="Select model"
351 + title="Select model (⌘/)"
352 + >
353 + {triggerLabel}
354 + <ChevronDown className="size-3.5 shrink-0 text-fg-subtle" />
355 + </button>
356 + )}
357 +
358 + <ResponsiveDialog
359 + open={open}
360 + onOpenChange={setOpen}
361 + title={multiple ? "Choose models" : "Choose a model"}
362 + hideTitle
363 + showClose={false}
364 + size="lg"
365 + snap="full"
366 + expandable={false}
367 + flush
368 + desktopFlush
369 + className="sm:h-[min(680px,86vh)]"
370 + bodyClassName="flex flex-col overflow-hidden"
371 + header={header}
372 + footer={
373 + multiple ? (
374 + <div className="flex items-center justify-between gap-3 text-[12.5px] text-fg-muted">
227 375 <span>{selected?.size ?? 0} selected (max 4)</span>
228 − <Button size="sm" onClick={() => setOpen(false)}>
376 + <Button size="sm" className="h-10 px-5 sm:h-8" onClick={() => setOpen(false)}>
229 377 Done
230 378 </Button>
231 379 </div>
232 − ) : (
233 − <div className="hidden items-center gap-3 border-t border-border px-3 py-1.5 text-[11px] text-fg-subtle sm:flex">
380 + ) : isMobile ? undefined : (
381 + <div className="flex items-center gap-3 text-[11px] text-fg-subtle">
234 382 <span>
235 383 <Kbd>↑↓</Kbd> navigate
236 384 </span>
@@ -241,12 +389,191 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi
241 389 <Kbd>⌘/</Kbd> toggle
242 390 </span>
243 391 <Badge variant="outline" className="ml-auto">
244 − {usable.length} models from {connectedProviders.size} provider{connectedProviders.size === 1 ? "" : "s"}
392 + {usable.length} models · {connectedProviders.size} provider{connectedProviders.size === 1 ? "" : "s"}
245 393 </Badge>
246 394 </div>
247 − )}
248 − </DialogContent>
249 − </Dialog>
395 + )
396 + }
397 + >
398 + {noProviders ? (
399 + <div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
400 + <KeyRound className="size-6 text-fg-subtle" />
401 + <p className="text-sm font-medium">No provider connected yet</p>
402 + <p className="max-w-sm text-[13px] text-fg-muted">Add an API key from any supported provider to unlock its models. Keys are encrypted and never leave the server.</p>
403 + <Button asChild variant="accent" size="sm">
404 + <Link href="/app/settings/providers">Connect a provider</Link>
405 + </Button>
406 + </div>
407 + ) : (
408 + <div
409 + ref={scrollRef}
410 + id="model-selector-list"
411 + role="listbox"
412 + aria-label="Models"
413 + tabIndex={-1}
414 + onKeyDown={onKeyNav}
415 + // The sheet's swipe-to-dismiss watches its own body (never scrolled here); only let it take over when our list is at the top.
416 + onPointerDown={(e) => {
417 + if ((scrollRef.current?.scrollTop ?? 0) > 0) e.stopPropagation();
418 + }}
419 + className="min-h-0 flex-1 overflow-y-auto pb-[max(12px,var(--sab))] scrollbar-thin contain-scroll" style={{ overflowAnchor: "none" }}>
420 + {searching && results && results.length === 0 ? (
421 + <div className="px-4 py-10 text-center">
422 + <p className="text-sm font-medium">No models match “{q}”</p>
423 + <p className="mt-1 text-[12.5px] text-fg-muted">Try a capability (“vision”, “reasoning”), a size (“1M context”), a price (“under $1/M”) or a provider (“gemini”).</p>
424 + </div>
425 + ) : (
426 + <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
427 + {virtualizer.getVirtualItems().map((v) => (
428 + <div key={v.key} data-index={v.index} ref={virtualizer.measureElement} style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${v.start}px)` }}>
429 + {renderItem(items[v.index], v.index)}
430 + </div>
431 + ))}
432 + </div>
433 + )}
434 + </div>
435 + )}
436 + </ResponsiveDialog>
437 +
438 + <ActionSheet open={Boolean(menuModel) && isMobile} onOpenChange={(v) => (!v ? setMenuModel(null) : null)} title={menuModel?.displayName} description={menuModel ? `${PROVIDERS[menuModel.provider].name} · ${menuModel.id}` : undefined} items={menuModel ? menuItems(menuModel) : []} />
439 +
440 + <PromptDialog open={Boolean(labelModel)} onOpenChange={(v) => (!v ? setLabelModel(null) : null)} title="Custom label" description={labelModel ? `Shown next to ${labelModel.displayName} everywhere in PolyLLM.` : undefined} label="Label" placeholder="My research model" defaultValue={labelModel ? labels[labelModel.key] ?? "" : ""} maxLength={60} onSubmit={(v) => (labelModel ? setLabel(labelModel.key, v) : undefined)} />
441 +
442 + <ModelProfile model={profileModel} open={Boolean(profileModel)} onOpenChange={(v) => (!v ? setProfileModel(null) : null)} compareWith={current?.key ?? null} onUse={(m) => pick(m)} />
250 443 </>
251 444 );
252 445 }
446 +
447 +/* ------------------------------------------------------------------------------------------------ */
448 +
449 +function AutoCard({ selected, active, mode, onMode, onPick, pick, id }: { selected: boolean; active: boolean; mode: RouterMode; onMode: (m: RouterMode) => void; onPick: () => void; pick: ReturnType<typeof recommendedModels> | null; id: string }) {
450 + const top = pick?.route.recommended ?? null;
451 + return (
452 + <div className="px-2 pt-2">
453 + <div id={id} role="option" aria-selected={selected} className={cn("rounded-xl border p-3 transition-colors", selected ? "border-accent bg-accent-soft/40" : active ? "border-border-strong bg-bg-subtle" : "border-border")}>
454 + <button type="button" onClick={onPick} className="flex w-full items-start gap-3 text-left">
455 + <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
456 + <Sparkles className="size-4" />
457 + </span>
458 + <span className="min-w-0 flex-1">
459 + <span className="flex items-center gap-2">
460 + <span className="text-[14px] font-semibold">Auto · Smart Router</span>
461 + {selected ? <Check className="size-4 text-accent" /> : null}
462 + </span>
463 + <span className="mt-0.5 block text-[12.5px] leading-4 text-fg-muted">Picks the best connected model for each prompt and shows you why before sending.</span>
464 + </span>
465 + </button>
466 + <Segmented size="sm" value={mode} onChange={onMode} ariaLabel="Router mode" options={ROUTER_MODES.map((r) => ({ value: r.value, label: r.label }))} className="mt-3 w-full" />
467 + <p className="mt-2 truncate text-[11.5px] text-fg-subtle">
468 + {top ? (
469 + <>
470 + Right now → <span className="font-medium text-fg-muted">{top.model.displayName}</span> · {explainRoute(top)}
471 + </>
472 + ) : (
473 + "Connect a provider to enable routing."
474 + )}
475 + </p>
476 + </div>
477 + </div>
478 + );
479 +}
480 +
481 +interface RowProps {
482 + id: string;
483 + model: PolyModel;
484 + ctx: BadgeContext;
485 + label?: string;
486 + fav: boolean;
487 + connected: boolean;
488 + selected: boolean;
489 + active: boolean;
490 + multiple: boolean;
491 + isMobile: boolean;
492 + why?: string[];
493 + onPick: () => void;
494 + onFav: () => void;
495 + onMenu: () => void;
496 + onHover: () => void;
497 + menuItems: () => ActionSheetItem[];
498 +}
499 +
500 +const stop = (e: React.SyntheticEvent) => e.stopPropagation();
501 +
502 +const SelectorRow = React.memo(function SelectorRow({ id, model: m, ctx, label, fav, connected, selected, active, multiple, isMobile, why, onPick, onFav, onMenu, onHover, menuItems }: RowProps) {
503 + const press = useLongPress({ onLongPress: onMenu, onClick: onPick, disabled: !isMobile });
504 + const disabled = !connected;
505 + return (
506 + <div
507 + id={id}
508 + role="option"
509 + aria-selected={selected}
510 + aria-disabled={disabled || undefined}
511 + tabIndex={-1}
512 + onMouseEnter={onHover}
513 + onClick={isMobile ? undefined : onPick}
514 + onKeyDown={(e) => {
515 + if (e.key === "Enter" || e.key === " ") {
516 + e.preventDefault();
517 + onPick();
518 + }
519 + }}
520 + {...press}
521 + className={cn("group mx-2 flex h-14 select-none items-center gap-2.5 rounded-lg px-2 transition-colors", active && !selected && "bg-bg-subtle", selected && "bg-accent-soft/50", disabled ? "cursor-default opacity-60" : "cursor-pointer")}
522 + >
523 + <ProviderIcon provider={m.provider} size={18} className="shrink-0" />
524 + <div className="min-w-0 flex-1">
525 + <div className="flex min-w-0 items-center gap-1.5">
526 + <span className="truncate text-[14px] font-medium leading-5">{m.displayName}</span>
527 + {label ? <span className="truncate text-[12px] font-medium text-accent">{label}</span> : null}
528 + <StatusBadge model={m} connected={connected} ctx={ctx} className="hidden min-[400px]:inline-flex" />
529 + <ModelBadges model={m} ctx={ctx} max={isMobile ? 1 : 3} size="xs" className="hidden min-[400px]:inline-flex" />
530 + </div>
531 + <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] tabular-nums text-fg-subtle">
532 + {m.limits?.contextTokens ? <span className="shrink-0">{formatTokens(m.limits.contextTokens)} ctx</span> : null}
533 + {m.pricing && (m.pricing.inputPerMillion !== undefined || m.pricing.outputPerMillion !== undefined) ? (
534 + <span className="shrink-0" title="USD per 1M tokens — input / output">
535 + · {formatPrice(m.pricing.inputPerMillion)} / {formatPrice(m.pricing.outputPerMillion)}
536 + </span>
537 + ) : null}
538 + <CapabilityGlyphs model={m} max={6} className="ml-1" />
539 + {why?.length ? <span className="hidden truncate text-accent/80 md:inline">· {why.join(", ")}</span> : null}
540 + </div>
541 + </div>
542 + {multiple && selected ? <Check className="size-4 shrink-0 text-accent" /> : null}
543 + <span className="flex shrink-0 items-center" onPointerDown={stop} onPointerUp={stop} onClick={stop}>
544 + <button
545 + type="button"
546 + onClick={onFav}
547 + aria-pressed={fav}
548 + aria-label={fav ? `Remove ${m.displayName} from favorites` : `Add ${m.displayName} to favorites`}
549 + className={cn("tap flex size-8 items-center justify-center rounded-md transition-colors hover:bg-bg-muted", fav ? "text-warning" : "text-fg-subtle hover:text-fg", !fav && "hover-reveal")}
550 + >
551 + <Star key={fav ? "on" : "off"} className={cn("size-4", fav && "fill-current animate-pop")} />
552 + </button>
553 + {isMobile ? (
554 + <button type="button" onClick={onMenu} className="tap flex size-8 items-center justify-center rounded-md text-fg-subtle" aria-label={`More actions for ${m.displayName}`}>
555 + <MoreHorizontal className="size-4" />
556 + </button>
557 + ) : (
558 + <DropdownMenu>
559 + <DropdownMenuTrigger asChild>
560 + <button type="button" className="hover-reveal flex size-8 items-center justify-center rounded-md text-fg-subtle hover:bg-bg-muted hover:text-fg data-[state=open]:opacity-100" aria-label={`More actions for ${m.displayName}`}>
561 + <MoreHorizontal className="size-4" />
562 + </button>
563 + </DropdownMenuTrigger>
564 + <DropdownMenuContent align="end" onClick={stop}>
565 + {menuItems().map((it) => (
566 + <React.Fragment key={it.key}>
567 + {it.key === "profile" ? <DropdownMenuSeparator /> : null}
568 + <DropdownMenuItem onSelect={it.onSelect}>
569 + <span className="[&_svg]:size-3.5">{it.icon}</span> {it.label}
570 + </DropdownMenuItem>
571 + </React.Fragment>
572 + ))}
573 + </DropdownMenuContent>
574 + </DropdownMenu>
575 + )}
576 + </span>
577 + </div>
578 + );
579 +});
added src/components/chat/router-card.tsx +98 −0
@@ -0,0 +1,98 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Sparkles, Wand2 } from "lucide-react";
4 +import type { RouteResult, RouterMode } from "@/lib/client/router";
5 +import { ROUTER_MODES, explainRoute } from "@/lib/client/router";
6 +import { ResponsiveDialog } from "@/components/ui/sheet";
7 +import { Button } from "@/components/ui/button";
8 +import { Switch } from "@/components/ui/switch";
9 +import { Segmented } from "@/components/ui/segmented";
10 +import { ProviderIcon } from "@/components/brand/provider-icon";
11 +import { PROVIDERS } from "@/lib/client/providers";
12 +import { ModelPickerLauncher } from "./model-launcher";
13 +import { cn, formatTokens } from "@/lib/utils";
14 +
15 +function usd(n: number | null): string {
16 + if (n === null) return "no public pricing";
17 + return n < 0.01 ? `≈ $${n.toFixed(4)}` : `≈ $${n.toFixed(2)}`;
18 +}
19 +
20 +/**
21 + * Smart Router recommendation shown before sending when the AUTO model is selected.
22 + * Transparent by design: recommendation + why + estimated cost + alternatives. Nothing is sent
23 + * until the user taps "Use recommendation" or picks a model.
24 + */
25 +export function RouterCard({ open, onOpenChange, result, mode, onModeChange, onUse, alwaysAuto, onAlwaysAutoChange }: { open: boolean; onOpenChange: (o: boolean) => void; result: RouteResult | null; mode: RouterMode; onModeChange: (m: RouterMode) => void; onUse: (modelKey: string) => void; alwaysAuto: boolean; onAlwaysAutoChange: (v: boolean) => void }) {
26 + const rec = result?.recommended ?? null;
27 + return (
28 + <ResponsiveDialog
29 + open={open}
30 + onOpenChange={onOpenChange}
31 + title={
32 + <span className="inline-flex items-center gap-2">
33 + <Wand2 className="size-4 text-accent" /> Smart Router
34 + </span>
35 + }
36 + description={result ? `Detected: ${result.analysis.signals.length ? result.analysis.signals.join(" · ") : `${result.analysis.task} prompt`} · ~${formatTokens(result.analysis.inputTokens)} input tokens` : undefined}
37 + size="md"
38 + footer={
39 + <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
40 + <label className="flex min-h-[44px] items-center gap-2 text-[13px] text-fg-muted sm:min-h-0 sm:mr-auto">
41 + <Switch size="sm" checked={alwaysAuto} onCheckedChange={onAlwaysAutoChange} aria-label="Always auto-route" />
42 + Always auto-route (don&apos;t ask again)
43 + </label>
44 + <div className="flex gap-2">
45 + <ModelPickerLauncher onChange={onUse} label="Choose another" className="flex-1 sm:flex-none" variant="outline" />
46 + <Button variant="accent" className="flex-1 sm:flex-none" disabled={!rec} onClick={() => rec && onUse(rec.model.key)}>
47 + <Sparkles /> Use recommendation
48 + </Button>
49 + </div>
50 + </div>
51 + }
52 + >
53 + <div className="space-y-4 pt-1">
54 + <Segmented<RouterMode> value={mode} onChange={onModeChange} size="sm" ariaLabel="Routing mode" className="max-w-full" options={ROUTER_MODES.map((m) => ({ value: m.value, label: m.label }))} />
55 + {rec ? (
56 + <button type="button" onClick={() => onUse(rec.model.key)} className="flex w-full items-start gap-3 rounded-xl border border-accent/50 bg-accent-soft/40 p-3 text-left transition-colors hover:bg-accent-soft/70">
57 + <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated">
58 + <ProviderIcon provider={rec.model.provider} size={16} />
59 + </span>
60 + <span className="min-w-0 flex-1">
61 + <span className="block text-[11px] font-medium uppercase tracking-wide text-accent">Recommended</span>
62 + <span className="block truncate text-[15px] font-semibold">{rec.model.displayName}</span>
63 + <span className="block text-[12.5px] text-fg-muted">
64 + {PROVIDERS[rec.model.provider]?.name ?? rec.model.provider} · {explainRoute(rec)}
65 + </span>
66 + <span className="mt-1 block text-[12px] tabular-nums text-fg-subtle">
67 + Estimated cost {usd(rec.estimatedUsd)}
68 + {rec.model.limits?.contextTokens ? ` · ${formatTokens(rec.model.limits.contextTokens)} context` : ""}
69 + </span>
70 + </span>
71 + </button>
72 + ) : (
73 + <p className="rounded-xl border border-dashed border-border p-4 text-center text-[13px] text-fg-muted">No connected model fits this prompt (check vision / file / context requirements).</p>
74 + )}
75 + {result?.alternatives.length ? (
76 + <div>
77 + <p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Alternatives</p>
78 + <ul className="divide-y divide-hairline overflow-hidden rounded-xl border border-border">
79 + {result.alternatives.map((a) => (
80 + <li key={a.model.key}>
81 + <button type="button" onClick={() => onUse(a.model.key)} className={cn("flex min-h-[48px] w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-bg-subtle")}>
82 + <ProviderIcon provider={a.model.provider} size={14} />
83 + <span className="min-w-0 flex-1">
84 + <span className="block truncate text-[13.5px] font-medium">{a.model.displayName}</span>
85 + <span className="block truncate text-[12px] text-fg-muted">{explainRoute(a)}</span>
86 + </span>
87 + <span className="shrink-0 text-[12px] tabular-nums text-fg-subtle">{usd(a.estimatedUsd)}</span>
88 + </button>
89 + </li>
90 + ))}
91 + </ul>
92 + </div>
93 + ) : null}
94 + <p className="text-[11.5px] text-fg-subtle">Routing is computed locally from your connected models, the prompt and the mode above — no request is sent until you confirm.</p>
95 + </div>
96 + </ResponsiveDialog>
97 + );
98 +}
added src/components/chat/use-speech.ts +92 −0
@@ -0,0 +1,92 @@
1 +"use client";
2 +import * as React from "react";
3 +
4 +/** Minimal typing for the Web Speech API (not in lib.dom for every TS target). */
5 +interface SpeechRecognitionLike {
6 + lang: string;
7 + continuous: boolean;
8 + interimResults: boolean;
9 + start(): void;
10 + stop(): void;
11 + abort(): void;
12 + onresult: ((ev: { resultIndex: number; results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }> }) => void) | null;
13 + onerror: ((ev: { error: string }) => void) | null;
14 + onend: (() => void) | null;
15 +}
16 +type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
17 +
18 +function getCtor(): SpeechRecognitionCtor | null {
19 + if (typeof window === "undefined") return null;
20 + const w = window as unknown as { SpeechRecognition?: SpeechRecognitionCtor; webkitSpeechRecognition?: SpeechRecognitionCtor };
21 + return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
22 +}
23 +
24 +/**
25 + * Voice dictation via `SpeechRecognition` / `webkitSpeechRecognition`. `supported` is false where the
26 + * API is missing (Firefox, some WebViews) so the microphone button can be hidden entirely.
27 + * `onText(final, interim)` receives the accumulated final transcript and the live interim text.
28 + */
29 +export function useSpeechDictation(onText: (final: string, interim: string) => void, opts: { lang?: string } = {}) {
30 + const supported = React.useSyncExternalStore(
31 + () => () => {},
32 + () => getCtor() !== null,
33 + () => false,
34 + );
35 + const [listening, setListening] = React.useState(false);
36 + const [error, setError] = React.useState<string | null>(null);
37 + const recRef = React.useRef<SpeechRecognitionLike | null>(null);
38 + const finalRef = React.useRef("");
39 + const cbRef = React.useRef(onText);
40 + React.useEffect(() => {
41 + cbRef.current = onText;
42 + }, [onText]);
43 +
44 + const stop = React.useCallback(() => {
45 + recRef.current?.stop();
46 + }, []);
47 +
48 + const start = React.useCallback(() => {
49 + const Ctor = getCtor();
50 + if (!Ctor) return;
51 + const rec = new Ctor();
52 + rec.lang = opts.lang ?? (typeof navigator !== "undefined" ? navigator.language : "en-US");
53 + rec.continuous = true;
54 + rec.interimResults = true;
55 + finalRef.current = "";
56 + rec.onresult = (ev) => {
57 + let interim = "";
58 + for (let i = ev.resultIndex; i < ev.results.length; i++) {
59 + const r = ev.results[i];
60 + const t = r[0]?.transcript ?? "";
61 + if (r.isFinal) finalRef.current += (finalRef.current && !/\s$/.test(finalRef.current) ? " " : "") + t.trim();
62 + else interim += t;
63 + }
64 + cbRef.current(finalRef.current, interim);
65 + };
66 + rec.onerror = (ev) => {
67 + setError(ev.error === "not-allowed" ? "Microphone access was denied." : ev.error === "no-speech" ? null : `Dictation error: ${ev.error}`);
68 + };
69 + rec.onend = () => {
70 + setListening(false);
71 + recRef.current = null;
72 + };
73 + recRef.current = rec;
74 + setError(null);
75 + setListening(true);
76 + try {
77 + rec.start();
78 + } catch {
79 + setListening(false);
80 + recRef.current = null;
81 + }
82 + }, [opts.lang]);
83 +
84 + const toggle = React.useCallback(() => {
85 + if (listening) stop();
86 + else start();
87 + }, [listening, start, stop]);
88 +
89 + React.useEffect(() => () => recRef.current?.abort(), []);
90 +
91 + return { supported, listening, error, start, stop, toggle };
92 +}
added src/components/endpoints/endpoint-row.tsx +151 −0
@@ -0,0 +1,151 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Activity, MoreHorizontal, Pencil, RefreshCw, Trash2, CheckCircle2, XCircle, CircleDashed, Zap, Lock, Globe } from "lucide-react";
4 +import { Button } from "@/components/ui/button";
5 +import { Badge } from "@/components/ui/badge";
6 +import { Tooltip } from "@/components/ui/tooltip";
7 +import { ActionSheet } from "@/components/ui/sheet";
8 +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
9 +import { ProviderIcon } from "@/components/brand/provider-icon";
10 +import { useIsMobile } from "@/lib/client/hooks";
11 +import { formatRelative, formatMs, formatNumber, cn } from "@/lib/utils";
12 +import type { PublicEndpoint } from "@/lib/client/types";
13 +
14 +const STATUS: Record<PublicEndpoint["status"], { label: string; variant: "success" | "danger" | "warning" | "default"; icon: React.ReactNode }> = {
15 + valid: { label: "Reachable", variant: "success", icon: <CheckCircle2 /> },
16 + invalid: { label: "Unreachable", variant: "danger", icon: <XCircle /> },
17 + error: { label: "Error", variant: "danger", icon: <XCircle /> },
18 + unverified: { label: "Not tested", variant: "warning", icon: <CircleDashed /> },
19 +};
20 +
21 +function hostOf(url: string): string {
22 + try {
23 + const u = new URL(url);
24 + return `${u.host}${u.pathname.replace(/\/$/, "")}`;
25 + } catch {
26 + return url;
27 + }
28 +}
29 +
30 +export function EndpointRow({ endpoint, testing, syncing, onTest, onSync, onEdit, onRemove }: { endpoint: PublicEndpoint; testing: boolean; syncing: boolean; onTest: () => void; onSync: () => void; onEdit: () => void; onRemove: () => void }) {
31 + const ui = STATUS[endpoint.status] ?? STATUS.unverified;
32 + const isMobile = useIsMobile();
33 + const [more, setMore] = React.useState(false);
34 + const total = endpoint.modelsAvailable ?? endpoint.discoveredModels.length + endpoint.manualModels.length;
35 + const items = [
36 + { key: "edit", label: "Edit endpoint", icon: <Pencil />, onSelect: onEdit },
37 + { key: "sync", label: "Refresh model list", icon: <RefreshCw />, onSelect: onSync, disabled: !endpoint.modelsPath || syncing },
38 + "separator" as const,
39 + { key: "remove", label: "Remove endpoint", icon: <Trash2 />, onSelect: onRemove, destructive: true },
40 + ];
41 + return (
42 + <li className={cn("panel p-4 sm:p-5", endpoint.status === "invalid" && "ring-1 ring-danger/30")}>
43 + <div className="flex items-start gap-3 sm:gap-4">
44 + <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-bg-elevated shadow-xs">
45 + <ProviderIcon provider="custom" size={20} />
46 + </span>
47 + <div className="min-w-0 flex-1">
48 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
49 + <h3 className="truncate text-[15px] font-semibold leading-6">{endpoint.name}</h3>
50 + <Badge variant={ui.variant}>
51 + {ui.icon} {ui.label}
52 + </Badge>
53 + {endpoint.isPrivate ? (
54 + <Tooltip content="Private / local address — only reachable when PolyLLM runs on the same network.">
55 + <Badge variant="outline">
56 + <Lock /> local
57 + </Badge>
58 + </Tooltip>
59 + ) : (
60 + <Badge variant="outline">
61 + <Globe /> {endpoint.baseUrl.startsWith("https") ? "https" : "http"}
62 + </Badge>
63 + )}
64 + </div>
65 + <p className="mt-0.5 truncate font-mono text-[12.5px] text-fg-muted">{hostOf(endpoint.baseUrl)}</p>
66 + <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2.5 text-xs sm:grid-cols-4">
67 + <div>
68 + <dt className="text-fg-subtle">Models</dt>
69 + <dd className="mt-0.5 text-fg tabular-nums">
70 + {formatNumber(total)}
71 + {endpoint.manualModels.length ? <span className="text-fg-subtle"> · {endpoint.manualModels.length} manual</span> : null}
72 + </dd>
73 + </div>
74 + <div>
75 + <dt className="text-fg-subtle">Latency</dt>
76 + <dd className={cn("mt-0.5 inline-flex items-center gap-1 tabular-nums", endpoint.status === "valid" ? "text-success" : "text-fg")}>
77 + {endpoint.lastLatencyMs !== null ? (
78 + <>
79 + <Zap className="size-3" /> {formatMs(endpoint.lastLatencyMs)}
80 + </>
81 + ) : (
82 + "—"
83 + )}
84 + </dd>
85 + </div>
86 + <div>
87 + <dt className="text-fg-subtle">Last tested</dt>
88 + <dd className="mt-0.5 text-fg">{formatRelative(endpoint.lastValidatedAt)}</dd>
89 + </div>
90 + <div>
91 + <dt className="text-fg-subtle">Auth</dt>
92 + <dd className="mt-0.5 truncate font-mono text-fg">{endpoint.hasKey ? endpoint.keyHint : endpoint.headerNames.length ? `${endpoint.headerNames.length} header${endpoint.headerNames.length === 1 ? "" : "s"}` : "none"}</dd>
93 + </div>
94 + {endpoint.lastValidationError ? (
95 + <div className="col-span-2 sm:col-span-4">
96 + <dt className="text-fg-subtle">Last error</dt>
97 + <dd className="mt-0.5 text-danger">{endpoint.lastValidationError}</dd>
98 + </div>
99 + ) : null}
100 + </dl>
101 + {endpoint.discoveredModels.length ? (
102 + <ul className="mt-2.5 flex flex-wrap gap-1.5">
103 + {endpoint.discoveredModels.slice(0, isMobile ? 4 : 8).map((m) => (
104 + <li key={m.id}>
105 + <Badge variant="outline" className="max-w-[200px] truncate font-mono text-[10.5px] text-fg-muted">
106 + {m.id}
107 + </Badge>
108 + </li>
109 + ))}
110 + {endpoint.discoveredModels.length > (isMobile ? 4 : 8) ? <li className="text-[11px] text-fg-subtle">+{endpoint.discoveredModels.length - (isMobile ? 4 : 8)} more</li> : null}
111 + </ul>
112 + ) : null}
113 + </div>
114 +
115 + <div className="hidden shrink-0 flex-col items-stretch gap-2 sm:flex">
116 + <Button size="sm" variant="outline" loading={testing} onClick={onTest}>
117 + <Activity /> Test connection
118 + </Button>
119 + <DropdownMenu>
120 + <DropdownMenuTrigger asChild>
121 + <Button size="sm" variant="ghost" aria-label="More actions">
122 + <MoreHorizontal /> More
123 + </Button>
124 + </DropdownMenuTrigger>
125 + <DropdownMenuContent align="end">
126 + <DropdownMenuItem onSelect={onEdit}>
127 + <Pencil /> Edit endpoint
128 + </DropdownMenuItem>
129 + <DropdownMenuItem onSelect={onSync} disabled={!endpoint.modelsPath || syncing}>
130 + <RefreshCw /> Refresh model list
131 + </DropdownMenuItem>
132 + <DropdownMenuSeparator />
133 + <DropdownMenuItem destructive onSelect={onRemove}>
134 + <Trash2 /> Remove endpoint
135 + </DropdownMenuItem>
136 + </DropdownMenuContent>
137 + </DropdownMenu>
138 + </div>
139 + </div>
140 + <div className="mt-3 flex gap-2 sm:hidden">
141 + <Button size="lg" variant="outline" className="h-11 flex-1" loading={testing} onClick={onTest}>
142 + <Activity /> Test
143 + </Button>
144 + <Button size="lg" variant="ghost" className="h-11 flex-1" onClick={() => setMore(true)} aria-haspopup="menu">
145 + <MoreHorizontal /> More
146 + </Button>
147 + </div>
148 + {isMobile ? <ActionSheet open={more} onOpenChange={setMore} title={endpoint.name} description={hostOf(endpoint.baseUrl)} items={items} /> : null}
149 + </li>
150 + );
151 +}
added src/components/endpoints/endpoint-sheet.tsx +355 −0
@@ -0,0 +1,355 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Eye, EyeOff, Plus, Trash2, Activity, ClipboardPaste, AlertTriangle, CheckCircle2, XCircle, Info } from "lucide-react";
4 +import { ResponsiveDialog } from "@/components/ui/sheet";
5 +import { Button } from "@/components/ui/button";
6 +import { Input, Field } from "@/components/ui/input";
7 +import { Switch } from "@/components/ui/switch";
8 +import { Badge } from "@/components/ui/badge";
9 +import { toast } from "@/components/ui/toast";
10 +import { api } from "@/lib/client/api";
11 +import { errorMessage } from "@/lib/client/humanize";
12 +import { formatMs, cn } from "@/lib/utils";
13 +import type { PublicEndpoint, ManualModel } from "@/lib/client/types";
14 +import { ENDPOINT_PRESETS } from "./presets";
15 +
16 +interface HeaderRow {
17 + key: string;
18 + value: string;
19 +}
20 +
21 +interface FormState {
22 + name: string;
23 + baseUrl: string;
24 + apiKey: string;
25 + clearKey: boolean;
26 + headers: HeaderRow[];
27 + discover: boolean;
28 + modelsPath: string;
29 + manualModels: ManualModel[];
30 +}
31 +
32 +const EMPTY: FormState = { name: "", baseUrl: "", apiKey: "", clearKey: false, headers: [], discover: true, modelsPath: "/models", manualModels: [] };
33 +
34 +function fromEndpoint(e: PublicEndpoint): FormState {
35 + return { name: e.name, baseUrl: e.baseUrl, apiKey: "", clearKey: false, headers: e.headerNames.map((k) => ({ key: k, value: "" })), discover: Boolean(e.modelsPath), modelsPath: e.modelsPath || "/models", manualModels: e.manualModels.map((m) => ({ ...m })) };
36 +}
37 +
38 +/** Client-side hint only — the server runs the authoritative SSRF check. */
39 +export function looksPrivate(raw: string): boolean {
40 + try {
41 + const u = new URL(raw);
42 + const h = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
43 + return h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal") || h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || /^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h) || !h.includes(".");
44 + } catch {
45 + return false;
46 + }
47 +}
48 +
49 +export interface TestOutcome {
50 + ok: boolean;
51 + latencyMs: number;
52 + modelsAvailable: number;
53 + error?: string;
54 + models?: { id: string; displayName: string }[];
55 +}
56 +
57 +export function EndpointSheet({ open, onOpenChange, endpoint, allowPrivate, onSaved }: { open: boolean; onOpenChange: (v: boolean) => void; endpoint: PublicEndpoint | null; allowPrivate: boolean; onSaved: (e: PublicEndpoint, test?: TestOutcome) => void }) {
58 + const editing = Boolean(endpoint);
59 + const [form, setForm] = React.useState<FormState>(EMPTY);
60 + const [preset, setPreset] = React.useState<string>("other");
61 + const [showKey, setShowKey] = React.useState(false);
62 + const [busy, setBusy] = React.useState<"save" | "test" | null>(null);
63 + const [error, setError] = React.useState<string | null>(null);
64 + const [test, setTest] = React.useState<TestOutcome | null>(null);
65 + const [canPaste, setCanPaste] = React.useState(false);
66 +
67 + React.useEffect(() => {
68 + if (!open) return;
69 + // eslint-disable-next-line react-hooks/set-state-in-effect
70 + setForm(endpoint ? fromEndpoint(endpoint) : EMPTY);
71 + setPreset(endpoint ? ENDPOINT_PRESETS.find((p) => endpoint.baseUrl.startsWith(p.baseUrl.replace(/\/v1$/, "")))?.id ?? "other" : "other");
72 + setError(null);
73 + setTest(null);
74 + setShowKey(false);
75 + setCanPaste(typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText));
76 + }, [open, endpoint]);
77 +
78 + const patch = (p: Partial<FormState>) => {
79 + setForm((f) => ({ ...f, ...p }));
80 + setError(null);
81 + };
82 +
83 + const applyPreset = (id: string) => {
84 + setPreset(id);
85 + const p = ENDPOINT_PRESETS.find((x) => x.id === id);
86 + if (!p || id === "other") return;
87 + patch({ name: form.name || p.name, baseUrl: p.baseUrl, modelsPath: p.modelsPath, discover: true });
88 + };
89 +
90 + const privateHost = looksPrivate(form.baseUrl);
91 + const urlOk = /^https?:\/\/\S+/i.test(form.baseUrl.trim());
92 + const canSave = form.name.trim().length > 0 && urlOk && (form.discover ? form.modelsPath.trim().length > 0 : form.manualModels.some((m) => m.id.trim()));
93 +
94 + const payload = () => {
95 + const headers: Record<string, string> = {};
96 + for (const h of form.headers) if (h.key.trim() && h.value.trim()) headers[h.key.trim()] = h.value.trim();
97 + const keepExistingHeaders = editing && form.headers.length > 0 && form.headers.every((h) => !h.value.trim()) && form.headers.every((h) => endpoint!.headerNames.includes(h.key.trim()));
98 + return {
99 + name: form.name.trim(),
100 + baseUrl: form.baseUrl.trim().replace(/\/+$/, ""),
101 + ...(form.clearKey ? { apiKey: null } : form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
102 + ...(keepExistingHeaders ? {} : { headers: Object.keys(headers).length ? headers : null }),
103 + modelsPath: form.discover ? form.modelsPath.trim() : "",
104 + manualModels: form.manualModels.filter((m) => m.id.trim()).map((m) => ({ ...m, id: m.id.trim(), displayName: m.displayName?.trim() || undefined, contextTokens: m.contextTokens || undefined })),
105 + };
106 + };
107 +
108 + const save = async (andTest: boolean) => {
109 + if (!canSave) return;
110 + setBusy(andTest ? "test" : "save");
111 + setError(null);
112 + try {
113 + if (editing) {
114 + const { endpoint: saved } = await api<{ endpoint: PublicEndpoint }>(`/api/endpoints/${endpoint!.id}`, { method: "PATCH", json: payload() });
115 + let outcome: TestOutcome | undefined;
116 + if (andTest) {
117 + const r = await api<{ ok: boolean; latencyMs: number; models: { id: string; displayName: string }[]; error?: string; endpoint: PublicEndpoint }>(`/api/endpoints/${endpoint!.id}/sync`, { method: "POST" });
118 + outcome = { ok: r.ok, latencyMs: r.latencyMs, modelsAvailable: r.models.length, error: r.error, models: r.models };
119 + setTest(outcome);
120 + onSaved(r.endpoint, outcome);
121 + if (r.ok) toast.success("Endpoint reachable", `${r.models.length} models · ${formatMs(r.latencyMs)}`);
122 + else toast.error("Endpoint test failed", r.error);
123 + return;
124 + }
125 + onSaved(saved);
126 + toast.success("Endpoint saved");
127 + onOpenChange(false);
128 + } else {
129 + const r = await api<{ endpoint: PublicEndpoint; test?: { ok: boolean; latencyMs: number; modelsAvailable: number; error?: string } }>("/api/endpoints", { method: "POST", json: { ...payload(), validate: andTest } });
130 + if (r.test) {
131 + const outcome: TestOutcome = { ...r.test, models: r.endpoint.discoveredModels.map((m) => ({ id: m.id, displayName: m.id })) };
132 + setTest(outcome);
133 + onSaved(r.endpoint, outcome);
134 + if (r.test.ok) toast.success(`${r.endpoint.name} connected`, `${r.test.modelsAvailable} models · ${formatMs(r.test.latencyMs)}`);
135 + else toast.warning("Saved, but the test failed", r.test.error);
136 + if (r.test.ok) onOpenChange(false);
137 + } else {
138 + onSaved(r.endpoint);
139 + toast.success("Endpoint saved", "Not tested yet.");
140 + onOpenChange(false);
141 + }
142 + }
143 + } catch (e) {
144 + setError(errorMessage(e, "Could not save the endpoint."));
145 + } finally {
146 + setBusy(null);
147 + }
148 + };
149 +
150 + const pasteKey = async () => {
151 + try {
152 + const t = (await navigator.clipboard.readText()).trim();
153 + if (t) patch({ apiKey: t, clearKey: false });
154 + } catch {
155 + toast.error("Could not read the clipboard");
156 + }
157 + };
158 +
159 + const presetMeta = ENDPOINT_PRESETS.find((p) => p.id === preset);
160 +
161 + return (
162 + <ResponsiveDialog
163 + open={open}
164 + onOpenChange={(v) => !busy && onOpenChange(v)}
165 + size="lg"
166 + snap="full"
167 + title={editing ? `Edit ${endpoint!.name}` : "Add custom endpoint"}
168 + description="Any server that speaks the OpenAI Chat Completions API."
169 + footer={
170 + <div className="flex flex-wrap gap-2">
171 + <Button variant="ghost" className="md:mr-auto" onClick={() => onOpenChange(false)} disabled={Boolean(busy)}>
172 + Cancel
173 + </Button>
174 + <Button variant="outline" className="flex-1 md:flex-none" disabled={!canSave} loading={busy === "test"} onClick={() => save(true)}>
175 + <Activity /> {editing ? "Save & test" : "Save & test connection"}
176 + </Button>
177 + <Button variant="accent" className="flex-1 md:flex-none" disabled={!canSave} loading={busy === "save"} onClick={() => save(false)}>
178 + {editing ? "Save" : "Save without testing"}
179 + </Button>
180 + </div>
181 + }
182 + >
183 + <div className="space-y-5 pt-1">
184 + {!editing ? (
185 + <div>
186 + <p className="mb-1.5 text-[12px] font-medium text-fg-muted">Start from a preset</p>
187 + <div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 scrollbar-none md:mx-0 md:flex-wrap md:px-0">
188 + {ENDPOINT_PRESETS.map((p) => (
189 + <button key={p.id} type="button" onClick={() => applyPreset(p.id)} aria-pressed={preset === p.id} className={cn("inline-flex h-9 shrink-0 items-center rounded-full border px-3.5 text-[13px] font-medium transition-colors", preset === p.id ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg")}>
190 + {p.name}
191 + </button>
192 + ))}
193 + </div>
194 + {presetMeta ? <p className="mt-1.5 text-[12px] leading-4 text-fg-subtle">{presetMeta.hint}</p> : null}
195 + </div>
196 + ) : null}
197 +
198 + <div className="grid gap-4 md:grid-cols-2">
199 + <Field label="Name" htmlFor="ep-name">
200 + <Input id="ep-name" value={form.name} onChange={(e) => patch({ name: e.target.value })} placeholder="My Ollama" maxLength={80} className="h-11 text-[16px] md:h-9 md:text-sm" />
201 + </Field>
202 + <Field label="Base URL" htmlFor="ep-url" hint="Up to and including /v1." error={form.baseUrl && !urlOk ? "Enter a full http(s) URL." : null}>
203 + <Input id="ep-url" value={form.baseUrl} onChange={(e) => patch({ baseUrl: e.target.value })} placeholder="http://localhost:11434/v1" inputMode="url" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-11 font-mono text-[16px] md:h-9 md:text-sm" />
204 + </Field>
205 + </div>
206 +
207 + {privateHost ? (
208 + <div className={cn("flex gap-2 rounded-lg px-3 py-2.5 text-xs leading-5", allowPrivate ? "bg-info-soft text-info" : "bg-warning-soft text-warning")}>
209 + {allowPrivate ? <Info className="mt-0.5 size-3.5 shrink-0" /> : <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />}
210 + <p>
211 + {allowPrivate ? (
212 + <>This server allows private addresses: the URL must be reachable from the machine running PolyLLM, not from your browser.</>
213 + ) : (
214 + <>
215 + <span className="font-medium">Requests are made by the PolyLLM server, not your browser.</span> On www.polyllm.io a localhost or LAN address is unreachable and will be rejected. Expose the server through a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) and use that public URL, or run PolyLLM on your own machine with <code className="font-mono">ALLOW_PRIVATE_ENDPOINTS=1</code>.
216 + </>
217 + )}
218 + </p>
219 + </div>
220 + ) : null}
221 +
222 + <Field label="API key (optional)" htmlFor="ep-key" hint={editing && endpoint!.hasKey && !form.clearKey && !form.apiKey ? `Current key ${endpoint!.keyHint} is kept unless you enter a new one.` : "Sent as Authorization: Bearer. Encrypted at rest, never shown again."}>
223 + <div className="flex gap-2">
224 + <div className="relative min-w-0 flex-1">
225 + <Input id="ep-key" type={showKey ? "text" : "password"} value={form.apiKey} disabled={form.clearKey} onChange={(e) => patch({ apiKey: e.target.value })} placeholder={editing && endpoint!.hasKey ? "Enter a new key to replace" : "Leave empty if the server needs none"} autoComplete="off" autoCapitalize="off" autoCorrect="off" spellCheck={false} className="h-11 pr-11 font-mono text-[16px] md:h-9 md:text-sm" />
226 + <button type="button" onClick={() => setShowKey((s) => !s)} className="tap absolute right-1.5 top-1/2 -translate-y-1/2 rounded-sm p-1.5 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label={showKey ? "Hide key" : "Show key"}>
227 + {showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
228 + </button>
229 + </div>
230 + {canPaste ? (
231 + <Button type="button" variant="outline" className="h-11 shrink-0 px-3 md:h-9" onClick={pasteKey} aria-label="Paste key">
232 + <ClipboardPaste />
233 + </Button>
234 + ) : null}
235 + </div>
236 + {editing && endpoint!.hasKey ? (
237 + <label className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
238 + <Switch size="sm" checked={form.clearKey} onCheckedChange={(v) => patch({ clearKey: v, apiKey: v ? "" : form.apiKey })} aria-label="Remove the stored key" />
239 + Remove the stored key
240 + </label>
241 + ) : null}
242 + </Field>
243 +
244 + <div>
245 + <div className="mb-1.5 flex items-center justify-between">
246 + <p className="text-[12px] font-medium text-fg-muted">Custom headers</p>
247 + <Button type="button" size="xs" variant="ghost" onClick={() => patch({ headers: [...form.headers, { key: "", value: "" }] })} disabled={form.headers.length >= 12}>
248 + <Plus /> Add header
249 + </Button>
250 + </div>
251 + {form.headers.length === 0 ? (
252 + <p className="text-[12px] text-fg-subtle">None. Useful for gateways (e.g. <code className="font-mono">X-Api-Key</code>, <code className="font-mono">CF-Access-Client-Id</code>).</p>
253 + ) : (
254 + <ul className="space-y-2">
255 + {form.headers.map((h, i) => (
256 + <li key={i} className="flex gap-2">
257 + <Input value={h.key} onChange={(e) => patch({ headers: form.headers.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)) })} placeholder="Header" aria-label={`Header ${i + 1} name`} autoCapitalize="off" spellCheck={false} className="h-11 w-2/5 font-mono text-[16px] md:h-9 md:text-sm" />
258 + <Input value={h.value} onChange={(e) => patch({ headers: form.headers.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)) })} placeholder={editing && endpoint!.headerNames.includes(h.key) && !h.value ? "•••••• (stored)" : "Value"} aria-label={`Header ${i + 1} value`} autoCapitalize="off" spellCheck={false} className="h-11 min-w-0 flex-1 font-mono text-[16px] md:h-9 md:text-sm" />
259 + <Button type="button" variant="ghost" size="icon-lg" className="h-11 md:size-9" aria-label="Remove header" onClick={() => patch({ headers: form.headers.filter((_, j) => j !== i) })}>
260 + <Trash2 />
261 + </Button>
262 + </li>
263 + ))}
264 + </ul>
265 + )}
266 + {editing && form.headers.some((h) => endpoint!.headerNames.includes(h.key) && !h.value) ? <p className="mt-1.5 text-[11px] text-fg-subtle">Stored header values are never shown. Leave them blank to keep them, or type a new value to replace; editing any value re-saves all headers.</p> : null}
267 + </div>
268 +
269 + <div className="panel divide-y divide-hairline">
270 + <div className="flex min-h-[52px] items-center justify-between gap-4 px-4 py-3">
271 + <div>
272 + <p className="text-[14px] font-medium">Discover models automatically</p>
273 + <p className="mt-0.5 text-[12px] text-fg-muted">GET {form.baseUrl.trim().replace(/\/+$/, "") || "{baseUrl}"}{form.discover ? form.modelsPath || "/models" : "…"}</p>
274 + </div>
275 + <Switch checked={form.discover} onCheckedChange={(v) => patch({ discover: v })} aria-label="Discover models automatically" />
276 + </div>
277 + {form.discover ? (
278 + <div className="px-4 py-3">
279 + <Field label="Models path" htmlFor="ep-models-path" hint="Relative to the base URL. OpenAI shape {data:[{id}]}, bare arrays and Ollama's {models:[{name}]} are understood.">
280 + <Input id="ep-models-path" value={form.modelsPath} onChange={(e) => patch({ modelsPath: e.target.value })} placeholder="/models" autoCapitalize="off" spellCheck={false} className="h-11 font-mono text-[16px] md:h-9 md:text-sm" />
281 + </Field>
282 + </div>
283 + ) : null}
284 + </div>
285 +
286 + <div>
287 + <div className="mb-1.5 flex items-center justify-between">
288 + <div>
289 + <p className="text-[12px] font-medium text-fg-muted">Manual models {form.discover ? "(optional overrides)" : ""}</p>
290 + <p className="text-[11px] text-fg-subtle">{form.discover ? "Declare capabilities for discovered ids (vision, tools, reasoning, context) or add ids the listing misses." : "Discovery is off: list every model id the server accepts."}</p>
291 + </div>
292 + <Button type="button" size="xs" variant="ghost" onClick={() => patch({ manualModels: [...form.manualModels, { id: "" }] })} disabled={form.manualModels.length >= 200}>
293 + <Plus /> Add model
294 + </Button>
295 + </div>
296 + {form.manualModels.length ? (
297 + <ul className="space-y-2">
298 + {form.manualModels.map((m, i) => {
299 + const upd = (p: Partial<ManualModel>) => patch({ manualModels: form.manualModels.map((x, j) => (j === i ? { ...x, ...p } : x)) });
300 + return (
301 + <li key={i} className="panel space-y-2 p-3">
302 + <div className="flex gap-2">
303 + <Input value={m.id} onChange={(e) => upd({ id: e.target.value })} placeholder="model id (e.g. llama3.1:8b)" aria-label={`Model ${i + 1} id`} autoCapitalize="off" spellCheck={false} className="h-11 min-w-0 flex-1 font-mono text-[16px] md:h-9 md:text-sm" />
304 + <Button type="button" variant="ghost" size="icon-lg" className="h-11 md:size-9" aria-label="Remove model" onClick={() => patch({ manualModels: form.manualModels.filter((_, j) => j !== i) })}>
305 + <Trash2 />
306 + </Button>
307 + </div>
308 + <div className="grid gap-2 sm:grid-cols-2">
309 + <Input value={m.displayName ?? ""} onChange={(e) => upd({ displayName: e.target.value })} placeholder="Display name (optional)" aria-label={`Model ${i + 1} display name`} className="h-11 text-[16px] md:h-9 md:text-sm" />
310 + <Input type="number" inputMode="numeric" min={1} value={m.contextTokens ?? ""} onChange={(e) => upd({ contextTokens: e.target.value ? Number(e.target.value) : undefined })} placeholder="Context tokens (optional)" aria-label={`Model ${i + 1} context tokens`} className="h-11 text-[16px] md:h-9 md:text-sm" />
311 + </div>
312 + <div className="flex flex-wrap gap-x-5 gap-y-2 text-[13px]">
313 + {(["vision", "tools", "reasoning"] as const).map((cap) => (
314 + <label key={cap} className="flex min-h-[32px] items-center gap-2 capitalize">
315 + <Switch size="sm" checked={Boolean(m[cap])} onCheckedChange={(v) => upd({ [cap]: v })} aria-label={`${cap} supported`} />
316 + {cap}
317 + </label>
318 + ))}
319 + </div>
320 + </li>
321 + );
322 + })}
323 + </ul>
324 + ) : (
325 + <p className="text-[12px] text-fg-subtle">Discovered models start as text-only. Add an entry to unlock vision, tools or reasoning controls for a specific id.</p>
326 + )}
327 + </div>
328 +
329 + {test ? (
330 + <div className={cn("rounded-lg px-3 py-2.5 text-xs leading-5", test.ok ? "bg-success-soft text-success" : "bg-danger-soft text-danger")}>
331 + <p className="flex items-center gap-1.5 font-medium">
332 + {test.ok ? <CheckCircle2 className="size-3.5" /> : <XCircle className="size-3.5" />}
333 + {test.ok ? `Reachable in ${formatMs(test.latencyMs)} · ${test.modelsAvailable} model${test.modelsAvailable === 1 ? "" : "s"}` : `Failed after ${formatMs(test.latencyMs)}`}
334 + </p>
335 + {!test.ok && test.error ? <p className="mt-0.5 text-fg-muted">{test.error}</p> : null}
336 + {test.ok && test.models?.length ? (
337 + <ul className="mt-2 flex flex-wrap gap-1.5">
338 + {test.models.slice(0, 30).map((m) => (
339 + <li key={m.id}>
340 + <Badge variant="outline" className="font-mono text-[10.5px] text-fg-muted">
341 + {m.id}
342 + </Badge>
343 + </li>
344 + ))}
345 + {test.models.length > 30 ? <li className="text-fg-muted">+{test.models.length - 30} more</li> : null}
346 + </ul>
347 + ) : null}
348 + </div>
349 + ) : null}
350 +
351 + {error ? <p className="rounded-lg bg-danger-soft px-3 py-2 text-xs text-danger">{error}</p> : null}
352 + </div>
353 + </ResponsiveDialog>
354 + );
355 +}
added src/components/endpoints/presets.ts +19 −0
@@ -0,0 +1,19 @@
1 +/** Local-first OpenAI-compatible servers. Base URLs are the documented defaults; users can edit them. */
2 +export interface EndpointPreset {
3 + id: string;
4 + name: string;
5 + baseUrl: string;
6 + modelsPath: string;
7 + hint: string;
8 + /** Does the server need a key by default? */
9 + keyOptional: boolean;
10 +}
11 +
12 +export const ENDPOINT_PRESETS: EndpointPreset[] = [
13 + { id: "ollama", name: "Ollama", baseUrl: "http://localhost:11434/v1", modelsPath: "/models", hint: "Default port 11434. Any value works as the key; leave it empty.", keyOptional: true },
14 + { id: "lmstudio", name: "LM Studio", baseUrl: "http://localhost:1234/v1", modelsPath: "/models", hint: "Start the local server in LM Studio (Developer tab). Loaded models are listed at /v1/models.", keyOptional: true },
15 + { id: "vllm", name: "vLLM", baseUrl: "http://localhost:8000/v1", modelsPath: "/models", hint: "`vllm serve <model>` exposes the OpenAI API on port 8000. Add the key if you started it with --api-key.", keyOptional: true },
16 + { id: "llamacpp", name: "llama.cpp", baseUrl: "http://localhost:8080/v1", modelsPath: "/models", hint: "`llama-server -m model.gguf` listens on 8080. Use --api-key to protect it.", keyOptional: true },
17 + { id: "mlx", name: "MLX", baseUrl: "http://localhost:8081/v1", modelsPath: "/models", hint: "`mlx_lm.server --port 8081` on Apple silicon. Models are loaded on demand.", keyOptional: true },
18 + { id: "other", name: "Other", baseUrl: "https://", modelsPath: "/models", hint: "Any server implementing POST /v1/chat/completions (TGI, LocalAI, Together, Groq, Fireworks…).", keyOptional: true },
19 +];
modified src/components/library/file-library-picker.tsx +92 −44
@@ -1,13 +1,20 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { FileText, Image as ImageIcon, Library } from "lucide-react";
3 +import { Check, Library, Search } from "lucide-react";
4 4 import { ResponsiveDialog } from "@/components/ui/sheet";
5 5 import { Button } from "@/components/ui/button";
6 −import { EmptyState } from "@/components/ui/misc";
6 +import { Input } from "@/components/ui/input";
7 +import { ChipRow } from "@/components/ui/segmented";
8 +import { EmptyState, Skeleton } from "@/components/ui/misc";
9 +import { toast } from "@/components/ui/toast";
7 10 import { useApi, api } from "@/lib/client/api";
8 11 import { useApp } from "@/components/app/store";
9 −import type { ProjectFile } from "@/lib/client/types";
12 +import { errorMessage } from "@/lib/client/humanize";
13 +import type { PublicProjectFile, PublicProject } from "@/lib/client/types";
10 14 import { formatTokens, formatRelative, cn } from "@/lib/utils";
15 +import { FileThumb } from "./file-list";
16 +import { formatBytes, kindLabel } from "./format";
17 +import { UploadButton } from "./upload-button";
11 18
12 19 export interface PickedAttachment {
13 20 id: string;
@@ -19,40 +26,61 @@ export interface PickedAttachment {
19 26 height?: number | null;
20 27 }
21 28
22 −type PublicProjectFile = Omit<ProjectFile, "dataBase64" | "createdAt"> & { createdAt: string };
29 +type Scope = "project" | "global" | "all";
30 +const MAX_PICK = 10;
23 31
24 32 /**
25 33 * Pick stored files (project / global library) to attach to a new message.
26 − * Calls POST /api/library/files/attach which copies the file into `message_attachments` and returns
27 − * pending attachment descriptors compatible with the composer.
28 − * (Owned by the Projects/Library workstream; baseline implementation.)
34 + * Multi-select (max 10), search, scope chips (this project / global / all), inline upload.
35 + * Calls POST /api/library/files/attach which copies the files into `message_attachments` and returns
36 + * pending attachment descriptors compatible with the composer (same shape as POST /api/attachments).
29 37 */
30 38 export function FileLibraryPicker({ open, onOpenChange, onPick, projectId }: { open: boolean; onOpenChange: (o: boolean) => void; onPick: (files: PickedAttachment[]) => void; projectId?: string | null }) {
31 39 const { activeProjectId } = useApp();
32 40 const pid = projectId ?? activeProjectId;
33 − const { data, isLoading } = useApi<{ files: PublicProjectFile[] }>(open ? `/api/library/files${pid ? `?projectId=${pid}` : ""}` : null);
41 + const { data, isLoading, mutate } = useApi<{ files: PublicProjectFile[] }>(open ? "/api/library/files" : null);
42 + const { data: projData } = useApi<{ projects: PublicProject[] }>(open ? "/api/projects" : null);
34 43 const [selected, setSelected] = React.useState<Set<string>>(new Set());
44 + const [q, setQ] = React.useState("");
45 + const [scope, setScope] = React.useState<Scope>(pid ? "project" : "all");
35 46 const [busy, setBusy] = React.useState(false);
36 47 React.useEffect(() => {
48 + if (!open) return;
37 49 // eslint-disable-next-line react-hooks/set-state-in-effect
38 − if (!open) setSelected(new Set());
39 − }, [open]);
50 + setSelected(new Set());
51 + setQ("");
52 + setScope(pid ? "project" : "all");
53 + }, [open, pid]);
54 +
55 + const project = React.useMemo(() => (pid ? projData?.projects.find((p) => p.id === pid) : undefined), [pid, projData]);
56 + const all = React.useMemo(() => data?.files ?? [], [data]);
57 + const files = React.useMemo(() => {
58 + const s = q.trim().toLowerCase();
59 + return all
60 + .filter((f) => (scope === "project" ? f.projectId === pid : scope === "global" ? !f.projectId : true))
61 + .filter((f) => (s ? f.name.toLowerCase().includes(s) || (f.description ?? "").toLowerCase().includes(s) || kindLabel(f.kind).toLowerCase().includes(s) : true));
62 + }, [all, scope, pid, q]);
63 + const counts = React.useMemo(() => ({ project: all.filter((f) => f.projectId === pid).length, global: all.filter((f) => !f.projectId).length, all: all.length }), [all, pid]);
40 64
41 − const files = data?.files ?? [];
42 65 const toggle = (id: string) =>
43 66 setSelected((s) => {
44 67 const n = new Set(s);
45 68 if (n.has(id)) n.delete(id);
46 − else n.add(id);
69 + else if (n.size < MAX_PICK) n.add(id);
70 + else toast.warning(`You can attach up to ${MAX_PICK} files at once`);
47 71 return n;
48 72 });
49 73
74 + const selectedTokens = React.useMemo(() => all.filter((f) => selected.has(f.id)).reduce((n, f) => n + (f.estimatedTokens ?? 0), 0), [all, selected]);
75 +
50 76 const attach = async () => {
51 77 setBusy(true);
52 78 try {
53 79 const res = await api<{ attachments: PickedAttachment[] }>("/api/library/files/attach", { method: "POST", json: { fileIds: [...selected] } });
54 80 onPick(res.attachments);
55 81 onOpenChange(false);
82 + } catch (e) {
83 + toast.error("Could not attach files", errorMessage(e));
56 84 } finally {
57 85 setBusy(false);
58 86 }
@@ -63,48 +91,68 @@ export function FileLibraryPicker({ open, onOpenChange, onPick, projectId }: { o
63 91 open={open}
64 92 onOpenChange={onOpenChange}
65 93 title="Attach from library"
66 − description={pid ? "Files saved to this project and your global library." : "Files saved to your library."}
94 + description={project ? `Files saved to “${project.name}” and your global library.` : "Files saved to your library."}
67 95 size="md"
96 + snap="full"
97 + flush
98 + header={
99 + <div className="space-y-2 px-4 pb-2 sm:px-6">
100 + <div className="relative">
101 + <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />
102 + <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search files…" className="h-10 pl-8 sm:h-9" aria-label="Search files" />
103 + </div>
104 + {pid ? <ChipRow value={scope} onChange={setScope} options={[{ value: "project", label: project?.name ?? "This project", count: counts.project }, { value: "global", label: "Global", count: counts.global }, { value: "all", label: "All", count: counts.all }]} /> : null}
105 + </div>
106 + }
68 107 footer={
69 108 <div className="flex items-center gap-2">
70 − <span className="text-[13px] text-fg-muted">{selected.size} selected</span>
109 + <span className="min-w-0 truncate text-[13px] text-fg-muted tabular-nums">
110 + {selected.size ? `${selected.size} selected${selectedTokens ? ` · ~${formatTokens(selectedTokens)} tokens` : ""}` : "Nothing selected"}
111 + </span>
71 112 <div className="flex-1" />
72 − <Button variant="ghost" onClick={() => onOpenChange(false)}>
73 − Cancel
74 − </Button>
113 + <UploadButton size="sm" variant="ghost" projectId={scope === "global" ? null : pid} onUploaded={() => mutate()}>
114 + Upload
115 + </UploadButton>
75 116 <Button disabled={!selected.size} loading={busy} onClick={attach}>
76 − Attach
117 + Attach{selected.size ? ` (${selected.size})` : ""}
77 118 </Button>
78 119 </div>
79 120 }
80 121 >
81 − {isLoading ? (
82 − <p className="py-8 text-center text-sm text-fg-muted">Loading…</p>
83 − ) : files.length === 0 ? (
84 − <EmptyState icon={<Library />} title="No stored files yet" description="Upload PDFs, images or documents once to a project and reuse them in any chat." className="border-0 py-8" />
85 − ) : (
86 − <ul className="divide-y divide-hairline">
87 − {files.map((f) => {
88 − const on = selected.has(f.id);
89 − return (
90 − <li key={f.id}>
91 − <button type="button" onClick={() => toggle(f.id)} className={cn("flex min-h-[52px] w-full items-center gap-3 px-1 py-2 text-left", on && "text-fg")} aria-pressed={on}>
92 − <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-muted text-fg-muted", on && "bg-accent-soft text-accent")}>{f.kind === "image" ? <ImageIcon className="size-4" /> : <FileText className="size-4" />}</span>
93 − <span className="min-w-0 flex-1">
94 − <span className="block truncate text-[14px] font-medium">{f.name}</span>
95 − <span className="block truncate text-[12px] text-fg-subtle">
96 − {(f.sizeBytes / 1024).toFixed(0)} KB{f.estimatedTokens ? ` · ~${formatTokens(f.estimatedTokens)} tokens` : ""} · {formatRelative(f.createdAt)}
122 + <div className="px-4 sm:px-6">
123 + {isLoading && !data ? (
124 + <div className="space-y-2 py-2">
125 + {Array.from({ length: 4 }).map((_, i) => (
126 + <Skeleton key={i} className="h-12" />
127 + ))}
128 + </div>
129 + ) : files.length === 0 ? (
130 + <EmptyState icon={<Library />} title={all.length ? "No matching files" : "No stored files yet"} description={all.length ? "Try another search or scope." : "Upload PDFs, images or documents once to a project and reuse them in any chat."} className="my-4 border-0 py-8" />
131 + ) : (
132 + <ul className="divide-y divide-hairline">
133 + {files.map((f) => {
134 + const on = selected.has(f.id);
135 + return (
136 + <li key={f.id}>
137 + <button type="button" onClick={() => toggle(f.id)} className={cn("flex min-h-[56px] w-full items-center gap-3 py-2 text-left", on && "text-fg")} aria-pressed={on}>
138 + <FileThumb file={f} />
139 + <span className="min-w-0 flex-1">
140 + <span className="block truncate text-[14px] font-medium">{f.name}</span>
141 + <span className="block truncate text-[12px] text-fg-subtle tabular-nums">
142 + {kindLabel(f.kind)} · {formatBytes(f.sizeBytes)}
143 + {f.estimatedTokens ? ` · ~${formatTokens(f.estimatedTokens)} tokens` : ""} · {formatRelative(f.createdAt)}
144 + </span>
145 + </span>
146 + <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border transition-colors", on ? "border-accent bg-accent text-accent-fg" : "border-border-strong")} aria-hidden>
147 + {on ? <Check className="size-3.5" /> : null}
97 148 </span>
98 − </span>
99 − <span className={cn("flex size-5 items-center justify-center rounded-full border", on ? "border-accent bg-accent text-accent-fg" : "border-border-strong")} aria-hidden>
100 − {on ? "✓" : ""}
101 − </span>
102 − </button>
103 − </li>
104 − );
105 − })}
106 − </ul>
107 − )}
149 + </button>
150 + </li>
151 + );
152 + })}
153 + </ul>
154 + )}
155 + </div>
108 156 </ResponsiveDialog>
109 157 );
110 158 }
added src/components/library/file-list.tsx +271 −0
@@ -0,0 +1,271 @@
1 +"use client";
2 +import * as React from "react";
3 +import { useRouter } from "next/navigation";
4 +import { Braces, Download, Eye, FileCode2, FileSpreadsheet, FileText, FolderInput, FolderKanban, Image as ImageIcon, MessageSquarePlus, Pencil, Trash2 } from "lucide-react";
5 +import { toast } from "@/components/ui/toast";
6 +import { Skeleton, EmptyState } from "@/components/ui/misc";
7 +import { ResponsiveDialog } from "@/components/ui/sheet";
8 +import { ConfirmDialog } from "@/components/common/confirm-dialog";
9 +import { PromptDialog } from "@/components/common/prompt-dialog";
10 +import { RowMenu } from "@/components/projects/row-menu";
11 +import { ProjectIcon } from "@/components/projects/project-icon";
12 +import { api } from "@/lib/client/api";
13 +import { errorMessage } from "@/lib/client/humanize";
14 +import { useIsMobile, useLongPress } from "@/lib/client/hooks";
15 +import { formatRelative, formatTokens, cn } from "@/lib/utils";
16 +import type { PublicProjectFile, PublicProject } from "@/lib/client/types";
17 +import type { ActionSheetItem } from "@/components/ui/sheet";
18 +import { formatBytes, kindLabel } from "./format";
19 +import { prepareFilesForNewChat } from "./use-in-chat";
20 +
21 +export function FileKindIcon({ kind, className }: { kind: string; className?: string }) {
22 + const c = cn("size-4", className);
23 + switch (kind) {
24 + case "image":
25 + return <ImageIcon className={c} />;
26 + case "csv":
27 + return <FileSpreadsheet className={c} />;
28 + case "json":
29 + return <Braces className={c} />;
30 + case "code":
31 + return <FileCode2 className={c} />;
32 + default:
33 + return <FileText className={c} />;
34 + }
35 +}
36 +
37 +/** 40 px thumbnail: real image preview for images, kind glyph otherwise. */
38 +export function FileThumb({ file, size = "md" }: { file: PublicProjectFile; size?: "sm" | "md" }) {
39 + const dim = size === "sm" ? "size-8 rounded-md" : "size-10 rounded-lg";
40 + if (file.kind === "image") {
41 + // eslint-disable-next-line @next/next/no-img-element
42 + return <img src={`/api/library/files/${file.id}`} alt="" className={cn("shrink-0 object-cover bg-bg-muted", dim)} loading="lazy" />;
43 + }
44 + return (
45 + <span className={cn("flex shrink-0 items-center justify-center bg-bg-muted text-fg-muted", dim)} aria-hidden>
46 + <FileKindIcon kind={file.kind} />
47 + </span>
48 + );
49 +}
50 +
51 +export interface FileListProps {
52 + files: PublicProjectFile[] | undefined;
53 + loading?: boolean;
54 + /** Known projects, for the project label and the "Move to…" actions. */
55 + projects?: PublicProject[];
56 + /** Show the project chip on each row (global library view). */
57 + showProject?: boolean;
58 + onChanged?: () => void | Promise<unknown>;
59 + /** When set, "Use in new chat" also activates this project. */
60 + contextProjectId?: string | null;
61 + emptyTitle?: string;
62 + emptyDescription?: string;
63 + emptyAction?: React.ReactNode;
64 + className?: string;
65 +}
66 +
67 +/** Stacked rows (phone) / dense rows (desktop) with per-file actions. */
68 +export function FileList({ files, loading, projects = [], showProject, onChanged, contextProjectId, emptyTitle = "No files yet", emptyDescription = "Upload PDFs, images, code or data once and reuse them in any chat.", emptyAction, className }: FileListProps) {
69 + const router = useRouter();
70 + const [preview, setPreview] = React.useState<PublicProjectFile | null>(null);
71 + const [editing, setEditing] = React.useState<PublicProjectFile | null>(null);
72 + const [deleting, setDeleting] = React.useState<PublicProjectFile | null>(null);
73 + const [moving, setMoving] = React.useState<PublicProjectFile | null>(null);
74 + const projectById = React.useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects]);
75 +
76 + const attachToNewChat = async (f: PublicProjectFile) => {
77 + try {
78 + const { href } = await prepareFilesForNewChat([f.id]);
79 + router.push(contextProjectId ? `${href}&project=${encodeURIComponent(contextProjectId)}` : href);
80 + } catch (e) {
81 + toast.error("Could not attach file", errorMessage(e));
82 + }
83 + };
84 + const download = (f: PublicProjectFile) => {
85 + const a = document.createElement("a");
86 + a.href = `/api/library/files/${f.id}?download=1`;
87 + a.download = f.name;
88 + a.click();
89 + };
90 + const move = async (f: PublicProjectFile, projectId: string | null) => {
91 + try {
92 + await api(`/api/library/files/${f.id}`, { method: "PATCH", json: { projectId } });
93 + await onChanged?.();
94 + toast.success(projectId ? `Moved to ${projectById.get(projectId)?.name ?? "project"}` : "Moved to the global library");
95 + } catch (e) {
96 + toast.error("Could not move file", errorMessage(e));
97 + }
98 + };
99 + const remove = async (f: PublicProjectFile) => {
100 + try {
101 + await api(`/api/library/files/${f.id}`, { method: "DELETE" });
102 + await onChanged?.();
103 + toast.success("File deleted");
104 + } catch (e) {
105 + toast.error("Could not delete file", errorMessage(e));
106 + throw e;
107 + }
108 + };
109 +
110 + const itemsFor = (f: PublicProjectFile): (ActionSheetItem | "separator")[] => [
111 + { key: "use", label: "Use in new chat", icon: <MessageSquarePlus />, onSelect: () => void attachToNewChat(f) },
112 + { key: "preview", label: f.kind === "pdf" ? "Open" : "Preview", icon: <Eye />, onSelect: () => (f.kind === "pdf" ? window.open(`/api/library/files/${f.id}`, "_blank", "noopener") : setPreview(f)) },
113 + { key: "download", label: "Download", icon: <Download />, onSelect: () => download(f) },
114 + "separator",
115 + { key: "move", label: f.projectId ? "Move to another project…" : "Move to a project…", icon: <FolderInput />, onSelect: () => setMoving(f), disabled: projects.length === 0 && !f.projectId },
116 + { key: "edit", label: "Edit description", icon: <Pencil />, onSelect: () => setEditing(f) },
117 + "separator",
118 + { key: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => setDeleting(f) },
119 + ];
120 +
121 + if (loading && !files) {
122 + return (
123 + <div className={cn("space-y-2", className)}>
124 + {Array.from({ length: 5 }).map((_, i) => (
125 + <Skeleton key={i} className="h-14" />
126 + ))}
127 + </div>
128 + );
129 + }
130 + if (!files?.length) return <EmptyState icon={<FileText />} title={emptyTitle} description={emptyDescription} action={emptyAction} className={className} />;
131 +
132 + return (
133 + <>
134 + <ul className={cn("divide-y divide-hairline rounded-xl border border-border bg-bg-elevated", className)}>
135 + {files.map((f) => (
136 + <FileRow key={f.id} file={f} project={showProject && f.projectId ? projectById.get(f.projectId) : undefined} items={itemsFor(f)} onOpen={() => (f.kind === "pdf" ? window.open(`/api/library/files/${f.id}`, "_blank", "noopener") : setPreview(f))} />
137 + ))}
138 + </ul>
139 +
140 + <FilePreviewSheet file={preview} onOpenChange={(o) => !o && setPreview(null)} onUse={attachToNewChat} onDownload={download} />
141 +
142 + <PromptDialog
143 + open={editing !== null}
144 + onOpenChange={(o) => !o && setEditing(null)}
145 + title="Description"
146 + description="Shown in the library and used as context when the file is injected into a prompt."
147 + defaultValue={editing?.description ?? ""}
148 + multiline
149 + maxLength={500}
150 + placeholder="Q3 pricing sheet, includes EU margins…"
151 + onSubmit={async (v) => {
152 + if (!editing) return;
153 + await api(`/api/library/files/${editing.id}`, { method: "PATCH", json: { description: v } });
154 + await onChanged?.();
155 + }}
156 + />
157 +
158 + <ResponsiveDialog open={moving !== null} onOpenChange={(o) => !o && setMoving(null)} title="Move file" description={moving?.name} size="sm">
159 + <ul className="divide-y divide-hairline pt-1">
160 + <li>
161 + <button type="button" className="flex min-h-[48px] w-full items-center gap-3 px-1 text-left text-[15px] sm:text-sm" disabled={!moving?.projectId} onClick={() => moving && (setMoving(null), void move(moving, null))}>
162 + <span className="flex size-8 items-center justify-center rounded-md bg-bg-muted text-fg-muted">
163 + <FolderKanban className="size-4" />
164 + </span>
165 + <span className="flex-1">Global library</span>
166 + {!moving?.projectId ? <span className="text-[12px] text-fg-subtle">current</span> : null}
167 + </button>
168 + </li>
169 + {projects
170 + .filter((p) => !p.archived)
171 + .map((p) => (
172 + <li key={p.id}>
173 + <button type="button" className="flex min-h-[48px] w-full items-center gap-3 px-1 text-left text-[15px] sm:text-sm disabled:opacity-50" disabled={moving?.projectId === p.id} onClick={() => moving && (setMoving(null), void move(moving, p.id))}>
174 + <ProjectIcon icon={p.icon} color={p.color} size="sm" className="size-8" />
175 + <span className="min-w-0 flex-1 truncate">{p.name}</span>
176 + {moving?.projectId === p.id ? <span className="text-[12px] text-fg-subtle">current</span> : null}
177 + </button>
178 + </li>
179 + ))}
180 + </ul>
181 + </ResponsiveDialog>
182 +
183 + <ConfirmDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)} destructive title={`Delete “${deleting?.name ?? ""}”?`} description="Messages that already used this file keep their own copy." confirmLabel="Delete" onConfirm={() => (deleting ? remove(deleting) : Promise.resolve())} />
184 + </>
185 + );
186 +}
187 +
188 +function FileRow({ file, project, items, onOpen }: { file: PublicProjectFile; project?: PublicProject; items: (ActionSheetItem | "separator")[]; onOpen: () => void }) {
189 + const isMobile = useIsMobile();
190 + const [menu, setMenu] = React.useState(false);
191 + const press = useLongPress({ onLongPress: () => setMenu(true), disabled: !isMobile });
192 + return (
193 + <li className="group flex min-h-[56px] items-center gap-3 px-3 py-2 sm:min-h-[52px]" {...press}>
194 + <button type="button" onClick={onOpen} className="flex min-w-0 flex-1 items-center gap-3 text-left" aria-label={`Preview ${file.name}`}>
195 + <FileThumb file={file} />
196 + <span className="min-w-0 flex-1">
197 + <span className="flex items-center gap-2">
198 + <span className="truncate text-[14px] font-medium leading-5">{file.name}</span>
199 + {project ? (
200 + <span className="hidden items-center gap-1 rounded-full bg-bg-muted px-1.5 py-0.5 text-[10.5px] text-fg-muted sm:inline-flex">
201 + <span aria-hidden>{project.icon ?? "◆"}</span> {project.name}
202 + </span>
203 + ) : null}
204 + </span>
205 + <span className="block truncate text-[12px] leading-4 text-fg-subtle tabular-nums">
206 + {kindLabel(file.kind)} · {formatBytes(file.sizeBytes)}
207 + {file.estimatedTokens ? ` · ~${formatTokens(file.estimatedTokens)} tokens` : ""} · {formatRelative(file.createdAt)}
208 + {project ? <span className="sm:hidden"> · {project.name}</span> : null}
209 + </span>
210 + {file.description ? <span className="mt-0.5 block truncate text-[12px] leading-4 text-fg-muted">{file.description}</span> : null}
211 + </span>
212 + </button>
213 + <RowMenu items={items} title={file.name} open={menu} onOpenChange={setMenu} />
214 + </li>
215 + );
216 +}
217 +
218 +/** Image / text preview in a sheet (PDFs open in a new tab). */
219 +function FilePreviewSheet({ file, onOpenChange, onUse, onDownload }: { file: PublicProjectFile | null; onOpenChange: (o: boolean) => void; onUse: (f: PublicProjectFile) => void; onDownload: (f: PublicProjectFile) => void }) {
220 + const [text, setText] = React.useState<string | null>(null);
221 + const isText = file && file.kind !== "image" && file.kind !== "pdf";
222 + React.useEffect(() => {
223 + if (!file || !isText) return;
224 + let cancelled = false;
225 + fetch(`/api/library/files/${file.id}`, { credentials: "same-origin" })
226 + .then((r) => r.text())
227 + .then((t) => {
228 + if (!cancelled) setText(t.length > 40_000 ? `${t.slice(0, 40_000)}\n… (${formatBytes(file.sizeBytes)} total)` : t);
229 + })
230 + .catch(() => {
231 + if (!cancelled) setText("Could not load the file.");
232 + });
233 + return () => {
234 + cancelled = true;
235 + setText(null);
236 + };
237 + }, [file, isText]);
238 + return (
239 + <ResponsiveDialog
240 + open={file !== null}
241 + onOpenChange={onOpenChange}
242 + title={file?.name ?? "Preview"}
243 + description={file ? `${kindLabel(file.kind)} · ${formatBytes(file.sizeBytes)}${file.estimatedTokens ? ` · ~${formatTokens(file.estimatedTokens)} tokens` : ""}` : undefined}
244 + size="lg"
245 + snap="full"
246 + footer={
247 + file ? (
248 + <div className="flex gap-2 sm:justify-end">
249 + <button type="button" className="tap inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md bg-bg-muted px-3 text-sm font-medium sm:h-9 sm:flex-none" onClick={() => onDownload(file)}>
250 + <Download className="size-4" /> Download
251 + </button>
252 + <button type="button" className="tap inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg sm:h-9 sm:flex-none" onClick={() => (onOpenChange(false), onUse(file))}>
253 + <MessageSquarePlus className="size-4" /> Use in new chat
254 + </button>
255 + </div>
256 + ) : undefined
257 + }
258 + >
259 + {file?.kind === "image" ? (
260 + // eslint-disable-next-line @next/next/no-img-element
261 + <img src={`/api/library/files/${file.id}`} alt={file.name} className="mx-auto max-h-[70dvh] w-auto max-w-full rounded-lg bg-bg-muted object-contain" />
262 + ) : isText ? (
263 + text === null ? (
264 + <Skeleton className="h-40" />
265 + ) : (
266 + <pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-lg border border-border bg-bg-subtle p-3 font-mono text-[12px] leading-5 text-fg-muted">{text}</pre>
267 + )
268 + ) : null}
269 + </ResponsiveDialog>
270 + );
271 +}
added src/components/library/format.ts +15 −0
@@ -0,0 +1,15 @@
1 +export function formatBytes(n: number | null | undefined): string {
2 + if (n === null || n === undefined || Number.isNaN(n)) return "—";
3 + if (n < 1024) return `${n} B`;
4 + if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
5 + return `${(n / (1024 * 1024)).toFixed(1)} MB`;
6 +}
7 +
8 +export const FILE_KIND_LABEL: Record<string, string> = { image: "Image", pdf: "PDF", text: "Text", code: "Code", csv: "CSV", json: "JSON", document: "Document" };
9 +
10 +export function kindLabel(kind: string): string {
11 + return FILE_KIND_LABEL[kind] ?? kind;
12 +}
13 +
14 +/** MIME/extension accept list shared by the upload inputs (mirrors /api/attachments + library `kindOf`). */
15 +export const LIBRARY_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,application/pdf,text/*,application/json,.md,.txt,.csv,.json,.js,.ts,.tsx,.jsx,.py,.rb,.go,.rs,.java,.kt,.swift,.c,.cc,.cpp,.h,.hpp,.cs,.php,.sh,.yaml,.yml,.toml,.xml,.html,.css,.sql";
added src/components/library/library-view.tsx +150 −0
@@ -0,0 +1,150 @@
1 +"use client";
2 +import * as React from "react";
3 +import { useRouter, useSearchParams } from "next/navigation";
4 +import { Globe, Library, Search, Upload } from "lucide-react";
5 +import { useApi } from "@/lib/client/api";
6 +import { errorMessage } from "@/lib/client/humanize";
7 +import { useIsMobile } from "@/lib/client/hooks";
8 +import { Button } from "@/components/ui/button";
9 +import { Input } from "@/components/ui/input";
10 +import { ChipRow } from "@/components/ui/segmented";
11 +import { EmptyState } from "@/components/ui/misc";
12 +import type { PublicProject, PublicProjectFile } from "@/lib/client/types";
13 +import { formatTokens, cn } from "@/lib/utils";
14 +import { WorkspacePageHeader, WorkspacePageBody } from "@/components/projects/page-header";
15 +import { ProjectIcon } from "@/components/projects/project-icon";
16 +import { FileList } from "./file-list";
17 +import { UploadButton, uploadLibraryFiles, reportUpload } from "./upload-button";
18 +import { formatBytes, kindLabel } from "./format";
19 +
20 +const KINDS = ["image", "pdf", "text", "code", "csv", "json"] as const;
21 +type KindFilter = (typeof KINDS)[number] | "all";
22 +
23 +/** `/app/library`: every stored file, filterable by project (global / per project) and kind, with drag & drop upload. */
24 +export function LibraryView() {
25 + const router = useRouter();
26 + const params = useSearchParams();
27 + const isMobile = useIsMobile();
28 + const initialScope = params.get("project") ?? "all";
29 + const [scope, setScope] = React.useState<string>(initialScope);
30 + const [kind, setKind] = React.useState<KindFilter>("all");
31 + const [q, setQ] = React.useState("");
32 + const [dragging, setDragging] = React.useState(false);
33 + const [uploading, setUploading] = React.useState<{ done: number; total: number } | null>(null);
34 + const files = useApi<{ files: PublicProjectFile[] }>("/api/library/files");
35 + const projects = useApi<{ projects: PublicProject[] }>("/api/projects?archived=1");
36 +
37 + const all = React.useMemo(() => files.data?.files ?? [], [files.data]);
38 + const projectList = React.useMemo(() => projects.data?.projects ?? [], [projects.data]);
39 + const projectById = React.useMemo(() => new Map(projectList.map((p) => [p.id, p])), [projectList]);
40 + // Projects that have at least one file, plus the one selected via ?project= (so the chip exists even when empty).
41 + const projectChips = React.useMemo(() => {
42 + const ids = new Set(all.map((f) => f.projectId).filter((x): x is string => Boolean(x)));
43 + if (scope !== "all" && scope !== "none") ids.add(scope);
44 + return projectList.filter((p) => ids.has(p.id));
45 + }, [all, projectList, scope]);
46 +
47 + const list = React.useMemo(() => {
48 + const s = q.trim().toLowerCase();
49 + return all
50 + .filter((f) => (scope === "all" ? true : scope === "none" ? !f.projectId : f.projectId === scope))
51 + .filter((f) => (kind === "all" ? true : f.kind === kind))
52 + .filter((f) => (s ? f.name.toLowerCase().includes(s) || (f.description ?? "").toLowerCase().includes(s) || kindLabel(f.kind).toLowerCase().includes(s) : true));
53 + }, [all, scope, kind, q]);
54 + const totals = React.useMemo(() => ({ bytes: list.reduce((n, f) => n + f.sizeBytes, 0), tokens: list.reduce((n, f) => n + (f.estimatedTokens ?? 0), 0) }), [list]);
55 + const kindCounts = React.useMemo(() => {
56 + const m = new Map<string, number>();
57 + for (const f of all) m.set(f.kind, (m.get(f.kind) ?? 0) + 1);
58 + return m;
59 + }, [all]);
60 +
61 + const uploadTarget = scope === "all" || scope === "none" ? null : scope;
62 + const changeScope = (s: string) => {
63 + setScope(s);
64 + router.replace(s === "all" ? "/app/library" : `/app/library?project=${encodeURIComponent(s)}`);
65 + };
66 +
67 + const onDrop = async (e: React.DragEvent) => {
68 + e.preventDefault();
69 + setDragging(false);
70 + const dropped = Array.from(e.dataTransfer.files ?? []);
71 + if (!dropped.length) return;
72 + setUploading({ done: 0, total: dropped.length });
73 + try {
74 + const res = await uploadLibraryFiles(dropped, uploadTarget, (done, total) => setUploading({ done, total }));
75 + reportUpload(res);
76 + await files.mutate();
77 + } finally {
78 + setUploading(null);
79 + }
80 + };
81 +
82 + const scopeLabel = scope === "all" ? "All files" : scope === "none" ? "Global library" : projectById.get(scope)?.name ?? "Project";
83 +
84 + return (
85 + <div
86 + className="relative flex h-full min-h-0 flex-col"
87 + onDragOver={(e) => {
88 + if (isMobile || !e.dataTransfer.types.includes("Files")) return;
89 + e.preventDefault();
90 + setDragging(true);
91 + }}
92 + onDragLeave={(e) => {
93 + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
94 + setDragging(false);
95 + }}
96 + onDrop={onDrop}
97 + >
98 + <WorkspacePageHeader title="Library" subtitle={all.length ? `${all.length} file${all.length === 1 ? "" : "s"} · ${formatBytes(all.reduce((n, f) => n + f.sizeBytes, 0))}` : undefined} actions={<UploadButton size="sm" projectId={uploadTarget} onUploaded={() => files.mutate()} />} />
99 + <WorkspacePageBody>
100 + {all.length > 0 ? (
101 + <div className="space-y-3">
102 + <div className="relative">
103 + <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />
104 + <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search files…" className="h-10 pl-8 sm:h-9 sm:max-w-sm" aria-label="Search files" />
105 + </div>
106 + <ChipRow
107 + value={scope}
108 + onChange={changeScope}
109 + className="-mx-4 px-4 sm:mx-0 sm:px-0"
110 + options={[
111 + { value: "all", label: "All", count: all.length },
112 + { value: "none", label: "Global", icon: <Globe />, count: all.filter((f) => !f.projectId).length },
113 + ...projectChips.map((p) => ({ value: p.id, label: p.name, icon: <ProjectIcon icon={p.icon} color={p.color} size="sm" className="size-4 text-[11px]" />, count: all.filter((f) => f.projectId === p.id).length })),
114 + ]}
115 + />
116 + {all.length > 3 ? <ChipRow value={kind} onChange={setKind} className="-mx-4 px-4 sm:mx-0 sm:px-0" options={[{ value: "all" as KindFilter, label: "Any type" }, ...KINDS.filter((k) => kindCounts.get(k)).map((k) => ({ value: k as KindFilter, label: kindLabel(k), count: kindCounts.get(k) }))]} /> : null}
117 + </div>
118 + ) : null}
119 +
120 + <div className={cn(all.length > 0 && "mt-4")}>
121 + {files.error ? (
122 + <EmptyState title="Could not load the library" description={errorMessage(files.error)} action={<Button variant="outline" onClick={() => files.mutate()}>Retry</Button>} />
123 + ) : !files.isLoading && all.length === 0 ? (
124 + <EmptyState icon={<Library />} title="Your library is empty" description="Upload PDFs, images, code or data once and attach them to any chat. Files saved to a project stay grouped with it." action={<UploadButton projectId={uploadTarget} onUploaded={() => files.mutate()} />} />
125 + ) : (
126 + <>
127 + {all.length > 0 ? (
128 + <p className="mb-2 text-[12px] text-fg-subtle tabular-nums">
129 + {scopeLabel} · {list.length} file{list.length === 1 ? "" : "s"} · {formatBytes(totals.bytes)}
130 + {totals.tokens ? ` · ~${formatTokens(totals.tokens)} tokens` : ""}
131 + </p>
132 + ) : null}
133 + <FileList files={files.data ? list : undefined} loading={files.isLoading} projects={projectList} showProject={scope === "all"} onChanged={() => files.mutate()} contextProjectId={uploadTarget} emptyTitle={q ? "No matching files" : kind !== "all" ? `No ${kindLabel(kind).toLowerCase()} files here` : "No files here yet"} emptyDescription={q ? "Try another search." : "Upload files to this scope, or pick another project."} emptyAction={!q ? <UploadButton projectId={uploadTarget} onUploaded={() => files.mutate()} /> : undefined} />
134 + </>
135 + )}
136 + </div>
137 + {!isMobile ? <p className="mt-6 text-center text-[12px] text-fg-subtle">Drop files anywhere on this page to upload them{uploadTarget ? ` to ${scopeLabel}` : ""}. Images, PDF, text, code, CSV, JSON · 10 MB max.</p> : null}
138 + </WorkspacePageBody>
139 +
140 + {dragging || uploading ? (
141 + <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-bg/80 backdrop-blur-[2px]" aria-live="polite">
142 + <div className="flex flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-accent bg-bg-elevated px-8 py-6 text-center">
143 + <Upload className="size-6 text-accent" />
144 + <p className="text-sm font-medium">{uploading ? `Uploading ${uploading.done}/${uploading.total}…` : `Drop to upload${uploadTarget ? ` to ${scopeLabel}` : ""}`}</p>
145 + </div>
146 + </div>
147 + ) : null}
148 + </div>
149 + );
150 +}
added src/components/library/upload-button.tsx +86 −0
@@ -0,0 +1,86 @@
1 +"use client";
2 +import * as React from "react";
3 +import { Upload } from "lucide-react";
4 +import { Button, type ButtonProps } from "@/components/ui/button";
5 +import { toast } from "@/components/ui/toast";
6 +import { errorMessage } from "@/lib/client/humanize";
7 +import { ClientApiError } from "@/lib/client/api";
8 +import { LIBRARY_ACCEPT } from "./format";
9 +import type { PublicProjectFile } from "@/lib/client/types";
10 +
11 +export const LIBRARY_MAX_BYTES = 10 * 1024 * 1024;
12 +
13 +/** Uploads files one by one to the library (multipart, same limits as chat attachments). Returns saved files + errors. */
14 +export async function uploadLibraryFiles(files: File[], projectId?: string | null, onProgress?: (done: number, total: number) => void): Promise<{ saved: PublicProjectFile[]; errors: { name: string; message: string }[] }> {
15 + const saved: PublicProjectFile[] = [];
16 + const errors: { name: string; message: string }[] = [];
17 + let done = 0;
18 + for (const f of files) {
19 + if (f.size > LIBRARY_MAX_BYTES) {
20 + errors.push({ name: f.name, message: "Larger than 10 MB" });
21 + } else {
22 + const form = new FormData();
23 + form.append("file", f);
24 + if (projectId) form.append("projectId", projectId);
25 + try {
26 + const res = await fetch("/api/library/files", { method: "POST", body: form, credentials: "same-origin" });
27 + if (!res.ok) {
28 + const body = (await res.json().catch(() => ({}))) as { error?: { code?: string; message?: string } };
29 + throw new ClientApiError(res.status, body.error?.code ?? "HTTP_ERROR", body.error?.message ?? `Upload failed (${res.status})`);
30 + }
31 + const body = (await res.json()) as { file: PublicProjectFile };
32 + saved.push(body.file);
33 + } catch (e) {
34 + errors.push({ name: f.name, message: errorMessage(e) });
35 + }
36 + }
37 + done += 1;
38 + onProgress?.(done, files.length);
39 + }
40 + return { saved, errors };
41 +}
42 +
43 +/** Summarises an upload batch in toasts. */
44 +export function reportUpload(result: { saved: PublicProjectFile[]; errors: { name: string; message: string }[] }) {
45 + if (result.saved.length) toast.success(result.saved.length === 1 ? `Added “${result.saved[0].name}”` : `Added ${result.saved.length} files to the library`);
46 + for (const e of result.errors.slice(0, 3)) toast.error(`Could not upload ${e.name}`, e.message);
47 + if (result.errors.length > 3) toast.error(`${result.errors.length - 3} more files failed`);
48 +}
49 +
50 +export function UploadButton({ projectId, onUploaded, children, size = "md", variant = "primary", className, ...rest }: { projectId?: string | null; onUploaded?: (files: PublicProjectFile[]) => unknown } & Omit<ButtonProps, "onClick">) {
51 + const inputRef = React.useRef<HTMLInputElement>(null);
52 + const [busy, setBusy] = React.useState<{ done: number; total: number } | null>(null);
53 +
54 + const onFiles = async (list: FileList | null) => {
55 + const files = list ? Array.from(list) : [];
56 + if (!files.length) return;
57 + setBusy({ done: 0, total: files.length });
58 + try {
59 + const result = await uploadLibraryFiles(files, projectId, (done, total) => setBusy({ done, total }));
60 + reportUpload(result);
61 + if (result.saved.length) await onUploaded?.(result.saved);
62 + } finally {
63 + setBusy(null);
64 + if (inputRef.current) inputRef.current.value = "";
65 + }
66 + };
67 +
68 + return (
69 + <>
70 + <input ref={inputRef} type="file" multiple accept={LIBRARY_ACCEPT} className="sr-only" tabIndex={-1} aria-hidden onChange={(e) => void onFiles(e.target.files)} />
71 + <Button size={size} variant={variant} className={className} loading={Boolean(busy)} onClick={() => inputRef.current?.click()} {...rest}>
72 + {busy ? (
73 + <span>
74 + {busy.done}/{busy.total}
75 + </span>
76 + ) : (
77 + children ?? (
78 + <>
79 + <Upload /> Upload
80 + </>
81 + )
82 + )}
83 + </Button>
84 + </>
85 + );
86 +}
added src/components/library/use-in-chat.ts +36 −0
@@ -0,0 +1,36 @@
1 +"use client";
2 +import { api } from "@/lib/client/api";
3 +import { PENDING_ATTACHMENTS_KEY } from "@/components/prompts/insert";
4 +import type { PendingAttachment } from "@/lib/client/types";
5 +
6 +/**
7 + * Library files → new chat hand-off (consumed by the Chat workstream).
8 + *
9 + * `useFilesInNewChat(fileIds)` copies the files into pending `message_attachments` (POST /api/library/files/attach),
10 + * stashes the descriptors in `sessionStorage["polyllm:pending-attachments"]` and returns the href
11 + * `/app/chat?attachments=<id,id>` to navigate to. A new chat view should, on mount, read `?attachments=` and
12 + * `consumePendingAttachments()` to seed the composer chips (the ids go straight into `message.attachmentIds`).
13 + */
14 +export async function prepareFilesForNewChat(fileIds: string[]): Promise<{ href: string; attachments: PendingAttachment[] }> {
15 + const res = await api<{ attachments: PendingAttachment[] }>("/api/library/files/attach", { method: "POST", json: { fileIds } });
16 + try {
17 + window.sessionStorage.setItem(PENDING_ATTACHMENTS_KEY, JSON.stringify({ at: Date.now(), attachments: res.attachments }));
18 + } catch {
19 + /* ignore */
20 + }
21 + return { href: `/app/chat?attachments=${encodeURIComponent(res.attachments.map((a) => a.id).join(","))}`, attachments: res.attachments };
22 +}
23 +
24 +/** Reads and clears stashed pending attachments (null when absent or older than 5 minutes). */
25 +export function consumePendingAttachments(): PendingAttachment[] | null {
26 + try {
27 + const raw = window.sessionStorage.getItem(PENDING_ATTACHMENTS_KEY);
28 + if (!raw) return null;
29 + window.sessionStorage.removeItem(PENDING_ATTACHMENTS_KEY);
30 + const d = JSON.parse(raw) as { at: number; attachments: PendingAttachment[] };
31 + if (!Array.isArray(d?.attachments) || Date.now() - (d.at ?? 0) > 5 * 60_000) return null;
32 + return d.attachments;
33 + } catch {
34 + return null;
35 + }
36 +}
modified src/components/markdown/code-block.tsx +69 −17
@@ -1,6 +1,8 @@
1 1 "use client";
2 2 import * as React from "react";
3 −import { Check, Copy, WrapText } from "lucide-react";
3 +import { Check, ChevronsDownUp, ChevronsUpDown, Copy, Download, WrapText } from "lucide-react";
4 +import { extensionForLanguage } from "@/lib/chat/markdown-blocks";
5 +import { useDebounced } from "@/lib/client/hooks";
4 6 import { cn } from "@/lib/utils";
5 7
6 8 type Highlighter = { codeToHtml: (code: string, opts: { lang: string; themes: { light: string; dark: string }; defaultColor: false | "light" }) => string; getLoadedLanguages: () => string[]; loadLanguage: (lang: never) => Promise<void> };
@@ -23,15 +25,23 @@ async function getHighlighter(): Promise<Highlighter> {
23 25
24 26 const ALIASES: Record<string, string> = { js: "javascript", ts: "typescript", sh: "bash", zsh: "bash", shell: "bash", py: "python", yml: "yaml", md: "markdown", rb: "ruby", rs: "rust", golang: "go", "c++": "cpp", cs: "csharp", kt: "kotlin", plaintext: "text", txt: "text" };
25 27
28 +/** Lines above which a block starts collapsed (Expand reveals everything). */
29 +const COLLAPSE_LINES = 28;
30 +
26 31 export function CodeBlock({ code, lang, wrap }: { code: string; lang?: string; wrap?: boolean }) {
27 32 const [html, setHtml] = React.useState<string | null>(null);
28 33 const [copied, setCopied] = React.useState(false);
29 34 const [localWrap, setLocalWrap] = React.useState<boolean | null>(null);
35 + const [expanded, setExpanded] = React.useState(false);
30 36 const language = React.useMemo(() => {
31 37 const l = (lang ?? "").toLowerCase().trim();
32 38 return ALIASES[l] ?? l;
33 39 }, [lang]);
34 40 const effectiveWrap = localWrap ?? wrap ?? false;
41 + // While streaming the block changes on every token; highlight the settled version only.
42 + const settled = useDebounced(code, 120);
43 + const lineCount = React.useMemo(() => (code ? code.split("\n").length : 0), [code]);
44 + const collapsible = lineCount > COLLAPSE_LINES;
35 45
36 46 React.useEffect(() => {
37 47 let cancelled = false;
@@ -52,7 +62,7 @@ export function CodeBlock({ code, lang, wrap }: { code: string; lang?: string; w
52 62 return;
53 63 }
54 64 }
55 − const out = hl.codeToHtml(code, { lang: language, themes: { light: "github-light-default", dark: "github-dark-default" }, defaultColor: "light" });
65 + const out = hl.codeToHtml(settled, { lang: language, themes: { light: "github-light-default", dark: "github-dark-default" }, defaultColor: "light" });
56 66 if (!cancelled) setHtml(out);
57 67 } catch {
58 68 if (!cancelled) setHtml(null);
@@ -61,7 +71,7 @@ export function CodeBlock({ code, lang, wrap }: { code: string; lang?: string; w
61 71 return () => {
62 72 cancelled = true;
63 73 };
64 − }, [code, language]);
74 + }, [settled, language]);
65 75
66 76 const copy = async () => {
67 77 try {
@@ -73,29 +83,71 @@ export function CodeBlock({ code, lang, wrap }: { code: string; lang?: string; w
73 83 }
74 84 };
75 85
86 + const download = () => {
87 + const ext = extensionForLanguage(language);
88 + const blob = new Blob([code], { type: "text/plain;charset=utf-8" });
89 + const url = URL.createObjectURL(blob);
90 + const a = document.createElement("a");
91 + a.href = url;
92 + a.download = ext === "Dockerfile" ? "Dockerfile" : `snippet.${ext}`;
93 + document.body.appendChild(a);
94 + a.click();
95 + a.remove();
96 + setTimeout(() => URL.revokeObjectURL(url), 1000);
97 + };
98 +
99 + // Show the highlighted HTML only when it matches the current code (avoids stale colouring while streaming).
100 + const showHtml = html && settled === code;
101 +
76 102 return (
77 103 <div className="group/code my-2 overflow-hidden rounded-lg border border-border bg-bg-subtle text-[13px]">
78 − <div className="flex h-8 items-center justify-between border-b border-border px-3">
79 − <span className="font-mono text-[11px] text-fg-subtle">{language || "text"}</span>
104 + <div className="flex h-9 items-center justify-between gap-2 border-b border-border px-2.5 sm:h-8 sm:px-3">
105 + <span className="truncate font-mono text-[11px] text-fg-subtle">
106 + {language || "text"}
107 + {lineCount > 1 ? <span className="hidden sm:inline"> · {lineCount} lines</span> : null}
108 + </span>
80 109 <div className="flex items-center gap-0.5">
81 − <button onClick={() => setLocalWrap((w) => !(w ?? wrap ?? false))} className={cn("rounded p-1 text-fg-subtle hover:bg-bg-muted hover:text-fg", effectiveWrap && "text-accent")} aria-label="Toggle line wrap" title="Toggle wrap">
110 + {collapsible ? (
111 + <ToolBtn label={expanded ? "Collapse" : "Expand"} onClick={() => setExpanded((e) => !e)}>
112 + {expanded ? <ChevronsDownUp className="size-3.5" /> : <ChevronsUpDown className="size-3.5" />}
113 + </ToolBtn>
114 + ) : null}
115 + <ToolBtn label={effectiveWrap ? "Disable line wrap" : "Wrap long lines"} onClick={() => setLocalWrap((w) => !(w ?? wrap ?? false))} active={effectiveWrap}>
82 116 <WrapText className="size-3.5" />
83 − </button>
84 − <button onClick={copy} className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label="Copy code">
117 + </ToolBtn>
118 + <ToolBtn label="Download" onClick={download}>
119 + <Download className="size-3.5" />
120 + </ToolBtn>
121 + <button onClick={copy} className="tap inline-flex h-7 items-center gap-1 rounded px-1.5 text-[11px] text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label="Copy code">
85 122 {copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
86 − {copied ? "Copied" : "Copy"}
123 + <span className="hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
87 124 </button>
88 125 </div>
89 126 </div>
90 − <div className={cn("overflow-x-auto scrollbar-thin", effectiveWrap && "[&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_code]:whitespace-pre-wrap")}>
91 − {html ? (
92 − <div className="[&_pre]:m-0 [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:leading-[1.55] [&_code]:font-mono [&_code]:text-[12.5px]" dangerouslySetInnerHTML={{ __html: html }} />
93 − ) : (
94 − <pre className="m-0 p-3 leading-[1.55]">
95 − <code className="font-mono text-[12.5px]">{code}</code>
96 − </pre>
97 − )}
127 + <div className={cn("relative", collapsible && !expanded && "max-h-[420px] overflow-hidden")}>
128 + <div className={cn("overflow-x-auto scrollbar-thin", effectiveWrap && "[&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_code]:whitespace-pre-wrap")}>
129 + {showHtml ? (
130 + <div className="[&_pre]:m-0 [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:leading-[1.55] [&_code]:font-mono [&_code]:text-[12.5px]" dangerouslySetInnerHTML={{ __html: html }} />
131 + ) : (
132 + <pre className="m-0 p-3 leading-[1.55]">
133 + <code className="font-mono text-[12.5px]">{code}</code>
134 + </pre>
135 + )}
136 + </div>
137 + {collapsible && !expanded ? (
138 + <button type="button" onClick={() => setExpanded(true)} className="absolute inset-x-0 bottom-0 flex h-16 items-end justify-center bg-gradient-to-t from-bg-subtle via-bg-subtle/80 to-transparent pb-2 text-[12px] font-medium text-fg-muted hover:text-fg">
139 + Show all {lineCount} lines
140 + </button>
141 + ) : null}
98 142 </div>
99 143 </div>
100 144 );
101 145 }
146 +
147 +function ToolBtn({ label, onClick, active, children }: { label: string; onClick: () => void; active?: boolean; children: React.ReactNode }) {
148 + return (
149 + <button type="button" onClick={onClick} className={cn("tap rounded p-1.5 text-fg-subtle hover:bg-bg-muted hover:text-fg", active && "text-accent")} aria-label={label} title={label}>
150 + {children}
151 + </button>
152 + );
153 +}
modified src/components/markdown/markdown.tsx +35 −3
@@ -2,9 +2,21 @@
2 2 import * as React from "react";
3 3 import ReactMarkdown, { type Components } from "react-markdown";
4 4 import remarkGfm from "remark-gfm";
5 +import remarkMath from "remark-math";
6 +import rehypeKatex from "rehype-katex";
7 +import "katex/dist/katex.min.css";
5 8 import { CodeBlock } from "./code-block";
9 +import { normalizeMath, splitStreamingMarkdown } from "@/lib/chat/markdown-blocks";
6 10 import { cn } from "@/lib/utils";
7 11
12 +/**
13 + * Chat Markdown renderer.
14 + *
15 + * - GFM (tables, task lists, strikethrough) + LaTeX (`$…$`, `$$…$$`, and the `\(…\)` / `\[…\]` forms
16 + * models emit, normalised in `lib/chat/markdown-blocks`). Raw HTML is skipped (never injected).
17 + * - Streaming: the text is split into completed top-level blocks that are rendered once and memoised;
18 + * only the trailing block re-parses on each token, so long answers stay cheap and tables/code do not flicker.
19 + */
8 20 const components = (wrap: boolean): Components => ({
9 21 code({ className, children, ...props }) {
10 22 const match = /language-(\w[\w+#-]*)/.exec(className ?? "");
@@ -36,13 +48,33 @@ const components = (wrap: boolean): Components => ({
36 48 },
37 49 });
38 50
51 +const REMARK_PLUGINS = [remarkGfm, remarkMath];
52 +const REHYPE_PLUGINS = [[rehypeKatex, { output: "html", throwOnError: false, strict: false }]] as never[];
53 +
54 +const Block = React.memo(function Block({ content, comps }: { content: string; comps: Components }) {
55 + return (
56 + <ReactMarkdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS} components={comps} skipHtml>
57 + {content}
58 + </ReactMarkdown>
59 + );
60 +});
61 +
39 62 export const Markdown = React.memo(function Markdown({ content, className, wrap = false, streaming = false }: { content: string; className?: string; wrap?: boolean; streaming?: boolean }) {
40 63 const comps = React.useMemo(() => components(wrap), [wrap]);
64 + const normalized = React.useMemo(() => normalizeMath(content), [content]);
65 + const split = React.useMemo(() => (streaming ? splitStreamingMarkdown(normalized, { minTailChars: 24 }) : null), [normalized, streaming]);
41 66 return (
42 67 <div className={cn("prose-chat", wrap && "wrap-code", streaming && "caret", className)}>
43 − <ReactMarkdown remarkPlugins={[remarkGfm]} components={comps} skipHtml>
44 − {content}
45 − </ReactMarkdown>
68 + {split ? (
69 + <>
70 + {split.blocks.map((b, i) => (
71 + <Block key={i} content={b} comps={comps} />
72 + ))}
73 + {split.tail ? <Block content={split.tail} comps={comps} /> : null}
74 + </>
75 + ) : (
76 + <Block content={normalized} comps={comps} />
77 + )}
46 78 </div>
47 79 );
48 80 });
modified src/components/marketing/demo.tsx +3 −39
@@ -1,380 +1,107 @@
1 1 "use client";
2 2 import * as React from "react";
3 3 import { useInView, useReducedMotion } from "motion/react";
4 −import { ChevronDown, FolderOpen, MessageSquare, Paperclip, Plus, RotateCcw, Search, Star } from "lucide-react";
5 −import { ProviderIcon } from "@/components/brand/provider-icon";
4 +import { BarChart3, Boxes, MessageSquare, RotateCcw, Swords } from "lucide-react";
6 5 import { Badge } from "@/components/ui/badge";
6 +import { Segmented } from "@/components/ui/segmented";
7 7 import { cn } from "@/lib/utils";
8 −import { Section, SectionHeading, Window } from "./section";
8 +import { Section, SectionHeading } from "./section";
9 9 import { Reveal } from "./reveal";
10 −import { MOCK_MODELS, type MockModel } from "./mock-data";
11 −
12 −/* ------------------------------------------------------------------------------------------------
13 − * Fake streaming engine — purely client-side, nothing is sent anywhere.
14 − * ---------------------------------------------------------------------------------------------- */
15 −interface Stream {
16 − text: string;
17 − /** characters per tick (~35 ms) */
18 − speed: number;
19 − /** ms before the first token */
20 − delay: number;
21 −}
22 −
23 −function useFakeStreams(streams: Stream[], active: boolean, replayKey: number, instant: boolean) {
24 − const [progress, setProgress] = React.useState<number[]>(() => streams.map(() => 0));
25 − const [elapsed, setElapsed] = React.useState(0);
26 − /** Wall time (ms since start) at which each stream finished; stats freeze at that point. */
27 − const [finishedAt, setFinishedAt] = React.useState<(number | null)[]>(() => streams.map(() => null));
28 −
29 − React.useEffect(() => {
30 − if (!active) return;
31 − if (instant) {
32 − // Reduced motion: show the finished state right away.
33 − const t = setTimeout(() => {
34 − setProgress(streams.map((s) => s.text.length));
35 − setFinishedAt(streams.map((s) => s.delay + 2400));
36 − setElapsed(2400);
37 − }, 0);
38 − return () => clearTimeout(t);
39 − }
40 − const started = performance.now();
41 − let cancelled = false;
42 − let raf = 0;
43 − let last = started;
44 − const finished: (number | null)[] = streams.map(() => null);
45 − const tick = (now: number) => {

Diff truncated — file too large.