1.0 workstreams A–G: chat/composer, model picker & catalog, Arena, projects/prompts/library, usage/providers/endpoints, marketing/onboarding, search/share/export
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
121 changed files +15,033 −2,891
modified
.env.example
+7 −0
@@ -33,6 +33,13 @@ KIMI_API_KEY= | ||
| 33 | 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'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]">'unsafe-inline'</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'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 & costs | |
| 146 | − </Item> | |
| 147 | − <Item onSelect={() => go("/app/settings/providers")} icon={<ShieldCheck />}> | |
| 148 | − Providers & 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 "…" | |
| 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'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.