SPB Git forge

spb/polyllm

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

Foundation for 1.0 upgrade: design tokens, mobile shell (bottom nav, drawer, keyboard inset), sheet primitives, brand (Prism P, favicon, OG, PWA manifest), workspace schema, router & token estimators, upgrade plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent f5497ba

41 changed files +6,051 −204

added docs/UPGRADE-PLAN.md +159 −0
@@ -0,0 +1,159 @@
1 +# PolyLLM 1.0 — "AI operating system" upgrade (2026-09-11)
2 +
3 +Massive product/UX/UI/mobile-first upgrade of www.polyllm.io. Goal: a premium, app-like multi-model workspace
4 +("One interface. Every model.") that feels native on iPhone/Android and excellent on desktop, without breaking any
5 +existing backend behaviour (auth, encrypted keys, chat history, Arena, presets, usage, exports, folders, attachments).
6 +
7 +This document is the shared contract between the parallel workstreams. Read it fully before touching code.
8 +
9 +## Ground rules (all workstreams)
10 +
11 +1. **Do not break existing functionality.** Reuse services in `src/lib/*`; extend, don't rewrite. Provider quirks stay in
12 + `src/lib/ai/providers/*`; components only see `PolyModel` capabilities (see `CLAUDE.md`).
13 +2. **Ownership.** Edit only the files/dirs listed for your workstream (plus new files you create there). If you need a change
14 + in someone else's file, add a small **adapter in your own area** and leave a `// TODO(integration: <area>)` comment, or
15 + put the request in `docs/upgrade-notes/<your-area>.md`. Exception: `src/lib/client/types.ts` — append-only.
16 +3. **Schema.** Tables for the upgrade live in `src/db/schema-workspace.ts` (already migrated: `drizzle/0001_workspace_upgrade.sql`).
17 + If you need more columns/tables: append to `schema-workspace.ts` (or create `src/db/schema-<area>.ts`, re-export it in
18 + `src/db/index.ts`), then run `pnpm db:generate --name <area>` **and** `pnpm db:migrate` locally. Never edit existing
19 + migration files. Never drop/rename columns.
20 +4. **No dev server in this phase.** Multiple `next dev` in the same tree conflict on `.next`. Validate with
21 + `pnpm typecheck`, `pnpm lint`, `pnpm test` (vitest) and, for server logic, small vitest units in `tests/unit`. Visual QA
22 + happens in the integration phase against a single dev server.
23 +5. **No fake functionality.** Every button works or is explicitly labelled "Coming soon" (use `<ComingSoon />` badge from
24 + `components/ui/misc` — add it if missing). No placeholder handlers.
25 +6. **Mobile first.** Design for 375 / 390 / 393 / 430 px widths first, then tablet, then desktop. Rules:
26 + - Phones: bottom sheets instead of centered modals (`ResponsiveDialog`, `BottomSheet`, `ActionSheet` in `components/ui/sheet.tsx`),
27 + long-press for message/row actions (`useLongPress`), swipe carousels (`.snap-row` + `useSnapCarousel`), 44 px touch
28 + targets (`.tap` helper or explicit min-h), 16 px inputs, `100dvh`/`.h-app`, safe areas (`pb-safe`, `pt-safe`, `--sab`).
29 + - Never `window.prompt/confirm/alert` — use `PromptDialog` / `ConfirmDialog` (`components/common`).
30 + - No desktop tables overflowing on phones: use stacked rows/cards below `md`, tables from `md` up.
31 + - No horizontal overflow, no clipped text, no fixed element blocking content. Respect `prefers-reduced-motion`.
32 + - Hover-only controls must also be reachable on touch (`.hover-reveal` shows them on coarse pointers).
33 +7. **Design language** (`src/app/globals.css` tokens): neutral warm graphite palette, iris accent, fewer borders, spatial
34 + grouping (`.panel`, hairlines) over boxed cards, restrained 150–300 ms motion, `text-balance` headlines, tabular numbers
35 + for metrics. Use `Segmented`/`ChipRow` (`components/ui/segmented.tsx`) for filters. Icons: lucide only.
36 +8. **Performance.** No new heavy dependencies without a note in your area doc (allowed if < ~40 KB gz and clearly justified —
37 + e.g. `katex` for LaTeX). Virtualize long lists (`@tanstack/react-virtual` is installed). Memoize streaming renders.
38 +9. **Accessibility.** Labels/aria on icon buttons, focus-visible, keyboard nav for lists/palettes, semantic elements.
39 +10. **Commit nothing.** The integrator commits. Leave the tree compiling (`pnpm typecheck && pnpm lint` green) when you finish.
40 +11. **Final report.** End with: files created/changed, new API routes (method, path, body → response), anything left as
41 + "Coming soon", and QA notes (what to check visually) in `docs/upgrade-notes/<area>.md`.
42 +
43 +## Shared primitives (already implemented — use them)
44 +
45 +| What | Where | Notes |
46 +| --- | --- | --- |
47 +| Tokens, utilities | `src/app/globals.css` | `.h-app`, `.pb-nav`, `.pb-safe`, `.tap`, `.hover-reveal`, `.snap-row`, `.panel`, `.glass-strong`, `.mask-x`, `.marquee`, `coarse:`/`fine:` variants |
48 +| Hooks | `src/lib/client/hooks.ts` | `useIsMobile`, `useIsTablet`, `useIsCoarsePointer`, `useMounted`, `useKeyboardInset`, `useLongPress`, `useSwipe`, `useLocalStorage`, `useDebounced`, `useCopy`, `useSnapCarousel`, `useStandalone` |
49 +| Sheets | `src/components/ui/sheet.tsx` | `BottomSheet` (handle, snap `content|half|full`, swipe-dismiss), `ResponsiveDialog` (sheet on phone, dialog/panel on desktop), `ActionSheet` (iOS-style list) |
50 +| Segmented / chips | `src/components/ui/segmented.tsx` | `Segmented`, `ChipRow` |
51 +| Dialog replacements | `src/components/common/prompt-dialog.tsx`, `confirm-dialog.tsx` | |
52 +| Shell | `src/components/app/shell.tsx`, `bottom-nav.tsx`, `sidebar.tsx`, `store.tsx` | Mobile bottom nav (Chat/Arena/Models/Usage/Account), drawer with edge-swipe, keyboard inset. Store adds `labels`, `setLabel`, `activeProjectId`, `searchOpen`, `AUTO_MODEL_KEY`. |
53 +| Token/cost estimator | `src/lib/client/tokens.ts` | `estimateTextTokens`, `estimateAttachmentTokens`, `estimateContext`, `estimateCost`, `formatEstimate`, `COST_CONFIRM_THRESHOLD_USD` |
54 +| Smart router | `src/lib/client/router.ts` | `analyzePrompt`, `routeModels`, `explainRoute`, `ROUTER_MODES` (pure, transparent) |
55 +| Brand | `src/components/brand/logo.tsx`, `public/icon.svg`, `public/og.png`, `scripts/brand-assets.sh`, `src/app/manifest.ts` | "Prism P" mark; `Wordmark` |
56 +| Schema | `src/db/schema-workspace.ts` | `projects`, `project_files`, `prompts`, `user_model_labels`, `custom_endpoints`, `arena_votes`, `shared_arena_sessions`; `conversations.project_id` |
57 +| Cross-area stubs | `components/projects/sidebar-section.tsx`, `components/library/file-library-picker.tsx` | Baselines; the Projects workstream owns/finishes them. Chat imports `FileLibraryPicker` from that path. |
58 +
59 +## Workstreams & ownership
60 +
61 +### A — Chat & composer (`components/chat/*` except `model-selector.tsx`, `model-config.tsx`, `model-badges.tsx`; `components/markdown/*`; `app/app/chat/*`; `lib/chat/*`; `app/api/chat/*`; `app/api/attachments/*`)
62 +Mobile composer (+ sheet: upload image/document, camera, paste, from library, tools, web search, structured output, system
63 +prompt, temporary chat), auto-grow 1→6 lines, voice dictation (Web Speech API where available, hidden otherwise), active model
64 +pill above composer (opens the model picker via `ModelSelector` — keep its public props), attachments (drag/drop, paste,
65 +camera, chips, multiple), context indicator (`43K / 200K`, warnings at 80/95 %), cost estimate + confirm above
66 +`COST_CONFIRM_THRESHOLD_USD` with one-tap model alternatives, Smart Router AUTO mode (`selectedModelKey === AUTO_MODEL_KEY`
67 +recommendation card before sending: Use / Choose another / Always auto-route), temporary chat (server: `ephemeral: true` in
68 +`chatRequestSchema` → stream without persisting; badge "Not stored in history"), message toolbar (desktop hover / mobile
69 +long-press ActionSheet: copy, retry, edit, branch, compare, change model from here, save as prompt, export, rate, more),
70 +expandable message metadata (model, provider, tokens, cost, TTFT, tok/s, reasoning level), deprecated-model warning with
71 +replacement suggestion, humanized provider errors with Retry / Switch model / Details, streaming performance (memoized
72 +blocks, stable markdown, code block Copy/Download/Wrap/Expand, LaTeX via `katex` + `rehype-katex` allowed), inline
73 +**Compare with…** (2–4 models; desktop side-by-side, mobile swipeable panels with sticky tabs; reuse `/api/arena` +
74 +`/api/arena/stream` to run, store as Arena session, "Continue with this model"), empty state with quick actions
75 +(Write / Code / Research / Analyze a file / Compare models), project instructions applied when `activeProjectId` is set
76 +(fetch `/api/projects/:id` — Projects workstream provides it; degrade gracefully if 404).
77 +
78 +### B — Models (`components/chat/model-selector.tsx`, `model-config.tsx`, `model-badges.tsx`; `app/app/models/*`; new `app/(marketing)/models/*`, `app/(marketing)/compare/*`; `components/models/*`; `lib/ai/registry/*` (additive); `app/api/models/*` (additive); `components/presets/*`, `app/app/presets/*`; `lib/models/*`)
79 +Model picker redesign (bottom sheet on phones; desktop dialog), fast search with intent parsing ("cheap vision model",
80 +"1M context", "under $1/M", "fastest gemini", "json schema"), sections (Favorites, Recent, Recommended, Fast, Best
81 +reasoning, Cheapest, Largest context, Vision, Coding, Open source, New), compact metadata rows, star favorites, custom labels
82 +(`store.setLabel`), AUTO entry (Smart Router) with mode selector, model badges (NEW/FAST/CHEAP/REASONING/VISION/CODING/LONG
83 +CONTEXT), lifecycle badges (New/Active/Deprecated/Retiring/Unavailable), model profile sheet (all fields; show "—" for
84 +unknown — never invent release dates or cutoffs), `/app/models` catalog (sortable, filters, mobile stacked rows, select →
85 +Compare), public `/models` and `/compare/[slug]` (`gpt-5.5-vs-claude-sonnet-5`) pages with SEO metadata reading the registry
86 +server-side (no auth), capability-aware settings grouped Generation / Reasoning / Output / Tools / Advanced with Reset and
87 +Save preset (presets apply across compatible models).
88 +
89 +### C — Arena (`components/arena/*`; `app/app/arena/*`; `lib/arena/*`; `app/api/arena/*`; new `app/share/arena/*`)
90 +Up to 4 models: desktop adaptive 2×2 / side-by-side, phone one panel at a time (`.snap-row`, sticky model tabs, swipe),
91 +live metrics (status, TTFT, tok/s, in/out tokens, cost), votes (Best / Most accurate / Best writing / Best coding / Best value
92 +/ Fastest + custom criteria) stored in `arena_votes`, Arena Winner summary with comparative metrics, Blind Arena (Model A/B/C
93 +until vote; reveal), history, personal scoreboard `/app/arena/scoreboard` (win rate per model, filters Coding/Research/
94 +Writing/Reasoning/Cost efficiency), exports (Markdown/JSON incl. models, parameters, metrics, ratings), public share of an
95 +Arena comparison (`shared_arena_sessions`, `/share/arena/[id]`, revoke).
96 +
97 +### D — Projects, prompt library, context library (`components/projects/*`, `components/library/*`, `components/prompts/*`; `app/app/projects/*`, `app/app/library/*`, `app/app/prompts/*`; `lib/projects/*`, `lib/library/*`, `lib/prompts/*`; `app/api/projects/*`, `app/api/library/*`, `app/api/prompts/*`)
98 +Projects CRUD (`/api/projects`, `/api/projects/[id]` returning `{ project, conversations, files, prompts }`), project page
99 +(instructions, preferred models, notes, files, conversations), `activeProjectId` selector, conversations filtered by project
100 +(add `projectId` to `ListFilter`/PATCH in `lib/conversations/service.ts` — small additive change allowed), file library
101 +(`/api/library/files` GET/POST/DELETE with `estimatedTokens`, `/api/library/files/attach` POST `{fileIds}``{attachments}`
102 +copying into `message_attachments`), prompt library with `{{variables}}` (parse, defaults, fill-in sheet before insert into
103 +composer via `?prompt=` / `router.push('/app/chat?promptId=…')` and a `usePromptInsert` event on `window`), folders/tags/
104 +favorites, keep existing `prompt_presets`/`model_presets` pages working (migrate the `/app/prompts` page to the new library
105 +while still listing legacy presets). Finish the two stubs listed above.
106 +
107 +### E — Usage analytics, providers, custom endpoints (`components/usage/*`; `app/app/usage/*`; `lib/usage/*`; `app/api/usage/*`; `app/app/settings/*`; `components/providers/*`; `lib/providers/*`; `app/api/providers/*`; new `lib/ai/providers/custom/*`, `app/api/endpoints/*`)
108 +Usage dashboard (KPIs: requests, tokens, cost, avg TTFT, tok/s, avg context, error rate; charts: cost/tokens/requests over
109 +time, cost by provider/model, requests by model, latency by model; filters Today/7d/30d/90d/Custom + provider/model/project;
110 +projections "Estimated monthly cost"; savings opportunities computed from real usage_records + pricing; mobile: stacked
111 +KPI tiles, chart cards full-width, tables → rows). Follow `dataviz` skill guidance (palette from tokens, accessible).
112 +Provider management redesign (status Connected/Not connected/Validation failed, last validated, models available, Test
113 +connection with latency, key rotation, remove), extensible provider list. Custom OpenAI-compatible endpoints (Ollama, LM
114 +Studio, vLLM, llama.cpp, MLX…): `custom_endpoints` table exists; implement adapter via `createOpenAICompatAdapter` with
115 +per-endpoint baseURL/headers, discovery (`GET {baseUrl}/models`) or manual models, models keyed `custom/<endpointId>:<modelId>`
116 +surfaced in `/api/models`, chat routing in `lib/chat/service.ts` resolution (coordinate via a `resolveCustomEndpoint()`
117 +helper you export; document the one-line hook needed in the chat service in your notes if you can't wire it without
118 +editing A's files — the integrator will wire it). Add `"custom"` to `PROVIDER_IDS` only if every switch over providers stays
119 +exhaustive-safe (grep first). Settings pages mobile-first (full-screen sections, bottom sheets).
120 +
121 +### F — Marketing, onboarding, auth, PWA polish (`components/marketing/*`; `app/(marketing)/*` except `models`/`compare`; `app/(auth)/*`; `components/app/onboarding.tsx`; `app/app/onboarding/*`; `app/sitemap.ts`; `public/*` (additive))
122 +Homepage redesign: hero "One interface. / Every model." + sub-line (Bring your own keys. Compare models. Control every
123 +parameter. Track every token.), CTAs "Start using PolyLLM" / "Try Arena", animated **real product UI** in the hero (no abstract
124 +art), live model strip (public `GET /api/public/models` summary — create it; marquee with provider marks, context, price,
125 +status), interactive demo switchable Chat/Arena/Models/Usage with simulated streaming and a phone-shaped mobile demo that
126 +mirrors the real mobile UI, features, security page `/security` (Browser → PolyLLM encrypted server layer → Provider; key
127 +lifecycle; AES-256-GCM, Argon2id, HttpOnly cookies, CSP, rate limits, audit logs — technically accurate only, verify in code),
128 +FAQ, footer with **contact@spboucher.ai · Simon-Pierre Boucher · Hosted on MacLustr (www.maclustr.io)**, `/contact` optional.
129 +Onboarding 6 steps (account → providers → first key → validate → favorite models → first prompt; skippable; progress;
130 +swipeable on phone) as a full page `/app/onboarding` + first-run redirect logic in `components/app/onboarding.tsx` (keep the
131 +existing `Onboarding` export working). Auth pages polish (mobile-first). Keep all metadata/OG (already set in `app/layout.tsx`).
132 +
133 +### G — Search, command palette, share/export (`components/app/command-palette.tsx`; new `components/search/*`; `app/api/search/*`; `lib/search/*`; `app/share/[id]/*`; `lib/conversations/service.ts` export/share functions (additive); `app/api/conversations/[id]/actions/route.ts` (additive))
134 +Full-text search API with filter syntax `model:claude project:research after:2026-08-01 before: provider: role:` over titles
135 ++ message content (Postgres `ilike`/`to_tsvector` — add a GIN index via a new migration if you use tsvector), grouped results,
136 +snippets with highlights; search UI: desktop inside ⌘K (search mode), phone full-screen sheet (`store.searchOpen`); universal
137 +command palette (New chat, Search, Switch model, Open Arena/Usage/Providers/Project, Upload file, Toggle theme, New temporary
138 +chat (`/app/chat?temporary=1`), Copy conversation URL, keyboard-first). Share: options Entire conversation / Selected messages
139 +(`messageIds`) with privacy warning sheet, revoke, list of active links in Settings → Data; share page redesign (mobile-first,
140 +brand). Export: Markdown, TXT, JSON, PDF (print-optimized route `/share/print/[id]` + `window.print()` is acceptable — label it
141 +"PDF (print)").
142 +
143 +## Navigation architecture
144 +
145 +- Desktop: sidebar (New chat, Search, Chat/Arena/Models/Prompts/Presets/Library/Usage, Projects, Pinned, Folders, Recent,
146 + account menu). Collapsible ⌘B.
147 +- Phone: bottom nav Chat / Arena / Models / Usage / Account; drawer (hamburger or edge-swipe) for conversations, projects,
148 + search; page headers use a compact 48 px bar with a hamburger on the left (`setSidebarOpen(true)`).
149 +- Every app page must reserve space for the bottom nav (the shell adds `padding-bottom`; pages use `h-full min-h-0 flex-col`
150 + layouts and scroll inside, never the document).
151 +
152 +## Integration phase (integrator)
153 +
154 +1. Wire cross-area hooks (custom endpoint resolution in chat service, project instructions, prompt insert).
155 +2. `pnpm typecheck && pnpm lint && pnpm test`, `pnpm build`.
156 +3. Single dev server → Playwright QA at 375×812, 390×844, 393×852, 430×932 + desktop 1440 for every screen listed in the brief
157 + (login, signup, onboarding, chat, composer+keyboard, attachments, model switcher, Arena + swipe, drawer, search, projects,
158 + catalog, usage, providers, account, sheets). Fix overflow/clipping/tap targets.
159 +4. Migration on prod via mld hook (`pnpm db:migrate`), deploy `mld stage . polyllm && mld deploy polyllm --node M3U96a`.
modified drizzle.config.ts +1 −1
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit";
2 2
3 3 export default defineConfig({
4 4 dialect: "postgresql",
5 schema: "./src/db/schema.ts",
5 + schema: ["./src/db/schema.ts", "./src/db/schema-*.ts"],
6 6 out: "./drizzle",
7 7 dbCredentials: { url: process.env.DATABASE_URL ?? "postgres://localhost:5432/polyllm" },
8 8 strict: true,
added drizzle/0001_workspace_upgrade.sql +122 −0
@@ -0,0 +1,122 @@
1 +CREATE TABLE "arena_votes" (
2 + "id" text PRIMARY KEY NOT NULL,
3 + "user_id" text NOT NULL,
4 + "session_id" text NOT NULL,
5 + "response_id" text NOT NULL,
6 + "model_key" text NOT NULL,
7 + "criterion" text NOT NULL,
8 + "category" text,
9 + "created_at" timestamp with time zone DEFAULT now() NOT NULL
10 +);
11 +--> statement-breakpoint
12 +CREATE TABLE "custom_endpoints" (
13 + "id" text PRIMARY KEY NOT NULL,
14 + "user_id" text NOT NULL,
15 + "name" text NOT NULL,
16 + "base_url" text NOT NULL,
17 + "encrypted_key" text,
18 + "key_hint" text,
19 + "encrypted_headers" text,
20 + "models_path" text DEFAULT '/models' NOT NULL,
21 + "manual_models" jsonb DEFAULT '[]'::jsonb NOT NULL,
22 + "status" text DEFAULT 'unverified' NOT NULL,
23 + "last_validated_at" timestamp with time zone,
24 + "last_validation_error" text,
25 + "last_latency_ms" integer,
26 + "models_available" integer,
27 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
28 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL
29 +);
30 +--> statement-breakpoint
31 +CREATE TABLE "project_files" (
32 + "id" text PRIMARY KEY NOT NULL,
33 + "user_id" text NOT NULL,
34 + "project_id" text,
35 + "kind" text NOT NULL,
36 + "name" text NOT NULL,
37 + "mime_type" text NOT NULL,
38 + "size_bytes" integer NOT NULL,
39 + "data_base64" text NOT NULL,
40 + "width" integer,
41 + "height" integer,
42 + "estimated_tokens" integer,
43 + "description" text,
44 + "created_at" timestamp with time zone DEFAULT now() NOT NULL
45 +);
46 +--> statement-breakpoint
47 +CREATE TABLE "projects" (
48 + "id" text PRIMARY KEY NOT NULL,
49 + "user_id" text NOT NULL,
50 + "name" text NOT NULL,
51 + "description" text,
52 + "icon" text,
53 + "color" text,
54 + "instructions" text,
55 + "preferred_model_keys" jsonb DEFAULT '[]'::jsonb NOT NULL,
56 + "default_settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
57 + "notes" text,
58 + "archived" boolean DEFAULT false NOT NULL,
59 + "sort_order" integer DEFAULT 0 NOT NULL,
60 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
61 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL
62 +);
63 +--> statement-breakpoint
64 +CREATE TABLE "prompts" (
65 + "id" text PRIMARY KEY NOT NULL,
66 + "user_id" text NOT NULL,
67 + "project_id" text,
68 + "kind" text DEFAULT 'user' NOT NULL,
69 + "name" text NOT NULL,
70 + "description" text,
71 + "content" text NOT NULL,
72 + "variables" jsonb DEFAULT '[]'::jsonb NOT NULL,
73 + "schema" jsonb,
74 + "folder" text,
75 + "tags" jsonb DEFAULT '[]'::jsonb NOT NULL,
76 + "favorite" boolean DEFAULT false NOT NULL,
77 + "default_model_key" text,
78 + "uses" integer DEFAULT 0 NOT NULL,
79 + "last_used_at" timestamp with time zone,
80 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
81 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL
82 +);
83 +--> statement-breakpoint
84 +CREATE TABLE "shared_arena_sessions" (
85 + "id" text PRIMARY KEY NOT NULL,
86 + "session_id" text NOT NULL,
87 + "user_id" text NOT NULL,
88 + "snapshot" jsonb NOT NULL,
89 + "is_public" boolean DEFAULT true NOT NULL,
90 + "view_count" integer DEFAULT 0 NOT NULL,
91 + "created_at" timestamp with time zone DEFAULT now() NOT NULL,
92 + "revoked_at" timestamp with time zone
93 +);
94 +--> statement-breakpoint
95 +CREATE TABLE "user_model_labels" (
96 + "user_id" text NOT NULL,
97 + "model_key" text NOT NULL,
98 + "label" text NOT NULL,
99 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
100 + CONSTRAINT "user_model_labels_user_id_model_key_pk" PRIMARY KEY("user_id","model_key")
101 +);
102 +--> statement-breakpoint
103 +ALTER TABLE "conversations" ADD COLUMN "project_id" text;--> statement-breakpoint
104 +ALTER TABLE "arena_votes" ADD CONSTRAINT "arena_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
105 +ALTER TABLE "custom_endpoints" ADD CONSTRAINT "custom_endpoints_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
106 +ALTER TABLE "project_files" ADD CONSTRAINT "project_files_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
107 +ALTER TABLE "project_files" ADD CONSTRAINT "project_files_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
108 +ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
109 +ALTER TABLE "prompts" ADD CONSTRAINT "prompts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
110 +ALTER TABLE "prompts" ADD CONSTRAINT "prompts_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
111 +ALTER TABLE "shared_arena_sessions" ADD CONSTRAINT "shared_arena_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
112 +ALTER TABLE "user_model_labels" ADD CONSTRAINT "user_model_labels_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
113 +CREATE UNIQUE INDEX "arena_votes_session_criterion_uq" ON "arena_votes" USING btree ("session_id","criterion");--> statement-breakpoint
114 +CREATE INDEX "arena_votes_user_model_idx" ON "arena_votes" USING btree ("user_id","model_key");--> statement-breakpoint
115 +CREATE INDEX "custom_endpoints_user_idx" ON "custom_endpoints" USING btree ("user_id");--> statement-breakpoint
116 +CREATE INDEX "project_files_user_idx" ON "project_files" USING btree ("user_id");--> statement-breakpoint
117 +CREATE INDEX "project_files_project_idx" ON "project_files" USING btree ("project_id");--> statement-breakpoint
118 +CREATE INDEX "projects_user_idx" ON "projects" USING btree ("user_id");--> statement-breakpoint
119 +CREATE INDEX "prompts_user_idx" ON "prompts" USING btree ("user_id");--> statement-breakpoint
120 +CREATE INDEX "prompts_user_folder_idx" ON "prompts" USING btree ("user_id","folder");--> statement-breakpoint
121 +CREATE INDEX "shared_arena_session_idx" ON "shared_arena_sessions" USING btree ("session_id");--> statement-breakpoint
122 +CREATE INDEX "conversations_user_project_idx" ON "conversations" USING btree ("user_id","project_id");
\ No newline at end of file
added drizzle/meta/0001_snapshot.json +3661 −0
@@ -0,0 +1,3661 @@
1 +{
2 + "id": "37eed628-caf4-40a5-a511-f6aad208f087",
3 + "prevId": "6f7e7f5c-d043-4e85-b3e5-b92b0ee9d52b",
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 + "created_at": {
2974 + "name": "created_at",
2975 + "type": "timestamp with time zone",
2976 + "primaryKey": false,
2977 + "notNull": true,
2978 + "default": "now()"
2979 + },
2980 + "updated_at": {
2981 + "name": "updated_at",
2982 + "type": "timestamp with time zone",
2983 + "primaryKey": false,
2984 + "notNull": true,
2985 + "default": "now()"
2986 + }
2987 + },
2988 + "indexes": {
2989 + "custom_endpoints_user_idx": {
2990 + "name": "custom_endpoints_user_idx",
2991 + "columns": [
2992 + {
2993 + "expression": "user_id",
2994 + "isExpression": false,
2995 + "asc": true,
2996 + "nulls": "last"
2997 + }
2998 + ],
2999 + "isUnique": false,
3000 + "concurrently": false,
3001 + "method": "btree",
3002 + "with": {}
3003 + }
3004 + },
3005 + "foreignKeys": {
3006 + "custom_endpoints_user_id_users_id_fk": {
3007 + "name": "custom_endpoints_user_id_users_id_fk",
3008 + "tableFrom": "custom_endpoints",
3009 + "tableTo": "users",
3010 + "columnsFrom": [
3011 + "user_id"
3012 + ],
3013 + "columnsTo": [
3014 + "id"
3015 + ],
3016 + "onDelete": "cascade",
3017 + "onUpdate": "no action"
3018 + }
3019 + },
3020 + "compositePrimaryKeys": {},
3021 + "uniqueConstraints": {},
3022 + "policies": {},
3023 + "checkConstraints": {},
3024 + "isRLSEnabled": false
3025 + },
3026 + "public.project_files": {
3027 + "name": "project_files",
3028 + "schema": "",
3029 + "columns": {
3030 + "id": {
3031 + "name": "id",
3032 + "type": "text",
3033 + "primaryKey": true,
3034 + "notNull": true
3035 + },
3036 + "user_id": {
3037 + "name": "user_id",
3038 + "type": "text",
3039 + "primaryKey": false,
3040 + "notNull": true
3041 + },
3042 + "project_id": {
3043 + "name": "project_id",
3044 + "type": "text",
3045 + "primaryKey": false,
3046 + "notNull": false
3047 + },
3048 + "kind": {
3049 + "name": "kind",
3050 + "type": "text",
3051 + "primaryKey": false,
3052 + "notNull": true
3053 + },
3054 + "name": {
3055 + "name": "name",
3056 + "type": "text",
3057 + "primaryKey": false,
3058 + "notNull": true
3059 + },
3060 + "mime_type": {
3061 + "name": "mime_type",
3062 + "type": "text",
3063 + "primaryKey": false,
3064 + "notNull": true
3065 + },
3066 + "size_bytes": {
3067 + "name": "size_bytes",
3068 + "type": "integer",
3069 + "primaryKey": false,
3070 + "notNull": true
3071 + },
3072 + "data_base64": {
3073 + "name": "data_base64",
3074 + "type": "text",
3075 + "primaryKey": false,
3076 + "notNull": true
3077 + },
3078 + "width": {
3079 + "name": "width",
3080 + "type": "integer",
3081 + "primaryKey": false,
3082 + "notNull": false
3083 + },
3084 + "height": {
3085 + "name": "height",
3086 + "type": "integer",
3087 + "primaryKey": false,
3088 + "notNull": false
3089 + },
3090 + "estimated_tokens": {
3091 + "name": "estimated_tokens",
3092 + "type": "integer",
3093 + "primaryKey": false,
3094 + "notNull": false
3095 + },
3096 + "description": {
3097 + "name": "description",
3098 + "type": "text",
3099 + "primaryKey": false,
3100 + "notNull": false
3101 + },
3102 + "created_at": {
3103 + "name": "created_at",
3104 + "type": "timestamp with time zone",
3105 + "primaryKey": false,
3106 + "notNull": true,
3107 + "default": "now()"
3108 + }
3109 + },
3110 + "indexes": {
3111 + "project_files_user_idx": {
3112 + "name": "project_files_user_idx",
3113 + "columns": [
3114 + {
3115 + "expression": "user_id",
3116 + "isExpression": false,
3117 + "asc": true,
3118 + "nulls": "last"
3119 + }
3120 + ],
3121 + "isUnique": false,
3122 + "concurrently": false,
3123 + "method": "btree",
3124 + "with": {}
3125 + },
3126 + "project_files_project_idx": {
3127 + "name": "project_files_project_idx",
3128 + "columns": [
3129 + {
3130 + "expression": "project_id",
3131 + "isExpression": false,
3132 + "asc": true,
3133 + "nulls": "last"
3134 + }
3135 + ],
3136 + "isUnique": false,
3137 + "concurrently": false,
3138 + "method": "btree",
3139 + "with": {}
3140 + }
3141 + },
3142 + "foreignKeys": {
3143 + "project_files_user_id_users_id_fk": {
3144 + "name": "project_files_user_id_users_id_fk",
3145 + "tableFrom": "project_files",
3146 + "tableTo": "users",
3147 + "columnsFrom": [
3148 + "user_id"
3149 + ],
3150 + "columnsTo": [
3151 + "id"
3152 + ],
3153 + "onDelete": "cascade",
3154 + "onUpdate": "no action"
3155 + },
3156 + "project_files_project_id_projects_id_fk": {
3157 + "name": "project_files_project_id_projects_id_fk",
3158 + "tableFrom": "project_files",
3159 + "tableTo": "projects",
3160 + "columnsFrom": [
3161 + "project_id"
3162 + ],
3163 + "columnsTo": [
3164 + "id"
3165 + ],
3166 + "onDelete": "cascade",
3167 + "onUpdate": "no action"
3168 + }
3169 + },
3170 + "compositePrimaryKeys": {},
3171 + "uniqueConstraints": {},
3172 + "policies": {},
3173 + "checkConstraints": {},
3174 + "isRLSEnabled": false
3175 + },
3176 + "public.projects": {
3177 + "name": "projects",
3178 + "schema": "",
3179 + "columns": {
3180 + "id": {
3181 + "name": "id",
3182 + "type": "text",
3183 + "primaryKey": true,
3184 + "notNull": true
3185 + },
3186 + "user_id": {
3187 + "name": "user_id",
3188 + "type": "text",
3189 + "primaryKey": false,
3190 + "notNull": true
3191 + },
3192 + "name": {
3193 + "name": "name",
3194 + "type": "text",
3195 + "primaryKey": false,
3196 + "notNull": true
3197 + },
3198 + "description": {
3199 + "name": "description",
3200 + "type": "text",
3201 + "primaryKey": false,
3202 + "notNull": false
3203 + },
3204 + "icon": {
3205 + "name": "icon",
3206 + "type": "text",
3207 + "primaryKey": false,
3208 + "notNull": false
3209 + },
3210 + "color": {
3211 + "name": "color",
3212 + "type": "text",
3213 + "primaryKey": false,
3214 + "notNull": false
3215 + },
3216 + "instructions": {
3217 + "name": "instructions",
3218 + "type": "text",
3219 + "primaryKey": false,
3220 + "notNull": false
3221 + },
3222 + "preferred_model_keys": {
3223 + "name": "preferred_model_keys",
3224 + "type": "jsonb",
3225 + "primaryKey": false,
3226 + "notNull": true,
3227 + "default": "'[]'::jsonb"
3228 + },
3229 + "default_settings": {
3230 + "name": "default_settings",
3231 + "type": "jsonb",
3232 + "primaryKey": false,
3233 + "notNull": true,
3234 + "default": "'{}'::jsonb"
3235 + },
3236 + "notes": {
3237 + "name": "notes",
3238 + "type": "text",
3239 + "primaryKey": false,
3240 + "notNull": false
3241 + },
3242 + "archived": {
3243 + "name": "archived",
3244 + "type": "boolean",
3245 + "primaryKey": false,
3246 + "notNull": true,
3247 + "default": false
3248 + },
3249 + "sort_order": {
3250 + "name": "sort_order",
3251 + "type": "integer",
3252 + "primaryKey": false,
3253 + "notNull": true,
3254 + "default": 0
3255 + },
3256 + "created_at": {
3257 + "name": "created_at",
3258 + "type": "timestamp with time zone",
3259 + "primaryKey": false,
3260 + "notNull": true,
3261 + "default": "now()"
3262 + },
3263 + "updated_at": {
3264 + "name": "updated_at",
3265 + "type": "timestamp with time zone",
3266 + "primaryKey": false,
3267 + "notNull": true,
3268 + "default": "now()"
3269 + }
3270 + },
3271 + "indexes": {
3272 + "projects_user_idx": {
3273 + "name": "projects_user_idx",
3274 + "columns": [
3275 + {
3276 + "expression": "user_id",
3277 + "isExpression": false,
3278 + "asc": true,
3279 + "nulls": "last"
3280 + }
3281 + ],
3282 + "isUnique": false,
3283 + "concurrently": false,
3284 + "method": "btree",
3285 + "with": {}
3286 + }
3287 + },
3288 + "foreignKeys": {
3289 + "projects_user_id_users_id_fk": {
3290 + "name": "projects_user_id_users_id_fk",
3291 + "tableFrom": "projects",
3292 + "tableTo": "users",
3293 + "columnsFrom": [
3294 + "user_id"
3295 + ],
3296 + "columnsTo": [
3297 + "id"
3298 + ],
3299 + "onDelete": "cascade",
3300 + "onUpdate": "no action"
3301 + }
3302 + },
3303 + "compositePrimaryKeys": {},
3304 + "uniqueConstraints": {},
3305 + "policies": {},
3306 + "checkConstraints": {},
3307 + "isRLSEnabled": false
3308 + },
3309 + "public.prompts": {
3310 + "name": "prompts",
3311 + "schema": "",
3312 + "columns": {
3313 + "id": {
3314 + "name": "id",
3315 + "type": "text",
3316 + "primaryKey": true,
3317 + "notNull": true
3318 + },
3319 + "user_id": {
3320 + "name": "user_id",
3321 + "type": "text",
3322 + "primaryKey": false,
3323 + "notNull": true
3324 + },
3325 + "project_id": {
3326 + "name": "project_id",
3327 + "type": "text",
3328 + "primaryKey": false,
3329 + "notNull": false
3330 + },
3331 + "kind": {
3332 + "name": "kind",
3333 + "type": "text",
3334 + "primaryKey": false,
3335 + "notNull": true,
3336 + "default": "'user'"
3337 + },
3338 + "name": {
3339 + "name": "name",
3340 + "type": "text",
3341 + "primaryKey": false,
3342 + "notNull": true
3343 + },
3344 + "description": {
3345 + "name": "description",
3346 + "type": "text",
3347 + "primaryKey": false,
3348 + "notNull": false
3349 + },
3350 + "content": {
3351 + "name": "content",
3352 + "type": "text",
3353 + "primaryKey": false,
3354 + "notNull": true
3355 + },
3356 + "variables": {
3357 + "name": "variables",
3358 + "type": "jsonb",
3359 + "primaryKey": false,
3360 + "notNull": true,
3361 + "default": "'[]'::jsonb"
3362 + },
3363 + "schema": {
3364 + "name": "schema",
3365 + "type": "jsonb",
3366 + "primaryKey": false,
3367 + "notNull": false
3368 + },
3369 + "folder": {
3370 + "name": "folder",
3371 + "type": "text",
3372 + "primaryKey": false,
3373 + "notNull": false
3374 + },
3375 + "tags": {
3376 + "name": "tags",
3377 + "type": "jsonb",
3378 + "primaryKey": false,
3379 + "notNull": true,
3380 + "default": "'[]'::jsonb"
3381 + },
3382 + "favorite": {
3383 + "name": "favorite",
3384 + "type": "boolean",
3385 + "primaryKey": false,
3386 + "notNull": true,
3387 + "default": false
3388 + },
3389 + "default_model_key": {
3390 + "name": "default_model_key",
3391 + "type": "text",
3392 + "primaryKey": false,
3393 + "notNull": false
3394 + },
3395 + "uses": {
3396 + "name": "uses",
3397 + "type": "integer",
3398 + "primaryKey": false,
3399 + "notNull": true,
3400 + "default": 0
3401 + },
3402 + "last_used_at": {
3403 + "name": "last_used_at",
3404 + "type": "timestamp with time zone",
3405 + "primaryKey": false,
3406 + "notNull": false
3407 + },
3408 + "created_at": {
3409 + "name": "created_at",
3410 + "type": "timestamp with time zone",
3411 + "primaryKey": false,
3412 + "notNull": true,
3413 + "default": "now()"
3414 + },
3415 + "updated_at": {
3416 + "name": "updated_at",
3417 + "type": "timestamp with time zone",
3418 + "primaryKey": false,
3419 + "notNull": true,
3420 + "default": "now()"
3421 + }
3422 + },
3423 + "indexes": {
3424 + "prompts_user_idx": {
3425 + "name": "prompts_user_idx",
3426 + "columns": [
3427 + {
3428 + "expression": "user_id",
3429 + "isExpression": false,
3430 + "asc": true,
3431 + "nulls": "last"
3432 + }
3433 + ],
3434 + "isUnique": false,
3435 + "concurrently": false,
3436 + "method": "btree",
3437 + "with": {}
3438 + },
3439 + "prompts_user_folder_idx": {
3440 + "name": "prompts_user_folder_idx",
3441 + "columns": [
3442 + {
3443 + "expression": "user_id",
3444 + "isExpression": false,
3445 + "asc": true,
3446 + "nulls": "last"
3447 + },
3448 + {
3449 + "expression": "folder",
3450 + "isExpression": false,
3451 + "asc": true,
3452 + "nulls": "last"
3453 + }
3454 + ],
3455 + "isUnique": false,
3456 + "concurrently": false,
3457 + "method": "btree",
3458 + "with": {}
3459 + }
3460 + },
3461 + "foreignKeys": {
3462 + "prompts_user_id_users_id_fk": {
3463 + "name": "prompts_user_id_users_id_fk",
3464 + "tableFrom": "prompts",
3465 + "tableTo": "users",
3466 + "columnsFrom": [
3467 + "user_id"
3468 + ],
3469 + "columnsTo": [
3470 + "id"
3471 + ],
3472 + "onDelete": "cascade",
3473 + "onUpdate": "no action"
3474 + },
3475 + "prompts_project_id_projects_id_fk": {
3476 + "name": "prompts_project_id_projects_id_fk",
3477 + "tableFrom": "prompts",
3478 + "tableTo": "projects",
3479 + "columnsFrom": [
3480 + "project_id"
3481 + ],
3482 + "columnsTo": [
3483 + "id"
3484 + ],
3485 + "onDelete": "set null",
3486 + "onUpdate": "no action"
3487 + }
3488 + },
3489 + "compositePrimaryKeys": {},
3490 + "uniqueConstraints": {},
3491 + "policies": {},
3492 + "checkConstraints": {},
3493 + "isRLSEnabled": false
3494 + },
3495 + "public.shared_arena_sessions": {
3496 + "name": "shared_arena_sessions",
3497 + "schema": "",
3498 + "columns": {
3499 + "id": {
3500 + "name": "id",
3501 + "type": "text",
3502 + "primaryKey": true,
3503 + "notNull": true
3504 + },
3505 + "session_id": {
3506 + "name": "session_id",
3507 + "type": "text",
3508 + "primaryKey": false,
3509 + "notNull": true
3510 + },
3511 + "user_id": {
3512 + "name": "user_id",
3513 + "type": "text",
3514 + "primaryKey": false,
3515 + "notNull": true
3516 + },
3517 + "snapshot": {
3518 + "name": "snapshot",
3519 + "type": "jsonb",
3520 + "primaryKey": false,
3521 + "notNull": true
3522 + },
3523 + "is_public": {
3524 + "name": "is_public",
3525 + "type": "boolean",
3526 + "primaryKey": false,
3527 + "notNull": true,
3528 + "default": true
3529 + },
3530 + "view_count": {
3531 + "name": "view_count",
3532 + "type": "integer",
3533 + "primaryKey": false,
3534 + "notNull": true,
3535 + "default": 0
3536 + },
3537 + "created_at": {
3538 + "name": "created_at",
3539 + "type": "timestamp with time zone",
3540 + "primaryKey": false,
3541 + "notNull": true,
3542 + "default": "now()"
3543 + },
3544 + "revoked_at": {
3545 + "name": "revoked_at",
3546 + "type": "timestamp with time zone",
3547 + "primaryKey": false,
3548 + "notNull": false
3549 + }
3550 + },
3551 + "indexes": {
3552 + "shared_arena_session_idx": {
3553 + "name": "shared_arena_session_idx",
3554 + "columns": [
3555 + {
3556 + "expression": "session_id",
3557 + "isExpression": false,
3558 + "asc": true,
3559 + "nulls": "last"
3560 + }
3561 + ],
3562 + "isUnique": false,
3563 + "concurrently": false,
3564 + "method": "btree",
3565 + "with": {}
3566 + }
3567 + },
3568 + "foreignKeys": {
3569 + "shared_arena_sessions_user_id_users_id_fk": {
3570 + "name": "shared_arena_sessions_user_id_users_id_fk",
3571 + "tableFrom": "shared_arena_sessions",
3572 + "tableTo": "users",
3573 + "columnsFrom": [
3574 + "user_id"
3575 + ],
3576 + "columnsTo": [
3577 + "id"
3578 + ],
3579 + "onDelete": "cascade",
3580 + "onUpdate": "no action"
3581 + }
3582 + },
3583 + "compositePrimaryKeys": {},
3584 + "uniqueConstraints": {},
3585 + "policies": {},
3586 + "checkConstraints": {},
3587 + "isRLSEnabled": false
3588 + },
3589 + "public.user_model_labels": {
3590 + "name": "user_model_labels",
3591 + "schema": "",
3592 + "columns": {
3593 + "user_id": {
3594 + "name": "user_id",
3595 + "type": "text",
3596 + "primaryKey": false,
3597 + "notNull": true
3598 + },
3599 + "model_key": {
3600 + "name": "model_key",
3601 + "type": "text",
3602 + "primaryKey": false,
3603 + "notNull": true
3604 + },
3605 + "label": {
3606 + "name": "label",
3607 + "type": "text",
3608 + "primaryKey": false,
3609 + "notNull": true
3610 + },
3611 + "updated_at": {
3612 + "name": "updated_at",
3613 + "type": "timestamp with time zone",
3614 + "primaryKey": false,
3615 + "notNull": true,
3616 + "default": "now()"
3617 + }
3618 + },
3619 + "indexes": {},
3620 + "foreignKeys": {
3621 + "user_model_labels_user_id_users_id_fk": {
3622 + "name": "user_model_labels_user_id_users_id_fk",
3623 + "tableFrom": "user_model_labels",
3624 + "tableTo": "users",
3625 + "columnsFrom": [
3626 + "user_id"
3627 + ],
3628 + "columnsTo": [
3629 + "id"
3630 + ],
3631 + "onDelete": "cascade",
3632 + "onUpdate": "no action"
3633 + }
3634 + },
3635 + "compositePrimaryKeys": {
3636 + "user_model_labels_user_id_model_key_pk": {
3637 + "name": "user_model_labels_user_id_model_key_pk",
3638 + "columns": [
3639 + "user_id",
3640 + "model_key"
3641 + ]
3642 + }
3643 + },
3644 + "uniqueConstraints": {},
3645 + "policies": {},
3646 + "checkConstraints": {},
3647 + "isRLSEnabled": false
3648 + }
3649 + },
3650 + "enums": {},
3651 + "schemas": {},
3652 + "sequences": {},
3653 + "roles": {},
3654 + "policies": {},
3655 + "views": {},
3656 + "_meta": {
3657 + "columns": {},
3658 + "schemas": {},
3659 + "tables": {}
3660 + }
3661 +}
\ No newline at end of file
modified drizzle/meta/_journal.json +7 −0
@@ -8,6 +8,13 @@
8 8 "when": 1788844960714,
9 9 "tag": "0000_foamy_loners",
10 10 "breakpoints": true
11 + },
12 + {
13 + "idx": 1,
14 + "version": "7",
15 + "when": 1789110687474,
16 + "tag": "0001_workspace_upgrade",
17 + "breakpoints": true
11 18 }
12 19 ]
13 20 }
\ No newline at end of file
modified package.json +2 −1
@@ -1,6 +1,6 @@
1 1 {
2 2 "name": "polyllm",
3 "version": "0.1.0",
3 + "version": "1.0.0",
4 4 "private": true,
5 5 "description": "PolyLLM — One interface. Every model. Bring your own keys.",
6 6 "type": "module",
@@ -18,6 +18,7 @@
18 18 "test:e2e": "playwright test",
19 19 "db:generate": "drizzle-kit generate",
20 20 "db:migrate": "tsx src/db/migrate.ts",
21 + "db:push": "drizzle-kit push",
21 22 "db:studio": "drizzle-kit studio",
22 23 "models:sync": "tsx --tsconfig tsconfig.json --import ./scripts/hooks.mjs scripts/sync-models.ts",
23 24 "providers:matrix": "tsx --tsconfig tsconfig.json --import ./scripts/hooks.mjs scripts/provider-matrix.ts"
added public/apple-icon.png +0 −0

Binary file not shown.

added public/brand/icon-maskable.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none"><defs><linearGradient id="g" x1="40" y1="40" x2="472" y2="472" gradientUnits="userSpaceOnUse"><stop stop-color="#6366f1"/><stop offset="0.52" stop-color="#8b5cf6"/><stop offset="1" stop-color="#f472b6"/></linearGradient><radialGradient id="h" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(140 90) rotate(48) scale(420 360)"><stop stop-color="#fff" stop-opacity="0.32"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs><rect width="512" height="512" fill="url(#g)"/><rect width="512" height="512" fill="url(#h)"/><g fill="#fff" transform="translate(76 76) scale(11.25)"><rect x="7.5" y="7" width="5" height="18" rx="2.5"/><path fill-rule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z"/><rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95"/><rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62"/><rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34"/></g></svg>
added public/brand/og.svg +32 −0
@@ -0,0 +1,32 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
2 + <defs>
3 + <linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse"><stop stop-color="#0b0c10"/><stop offset="1" stop-color="#12131c"/></linearGradient>
4 + <linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#6366f1"/><stop offset="0.52" stop-color="#8b5cf6"/><stop offset="1" stop-color="#f472b6"/></linearGradient>
5 + <radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(900 120) scale(620 420)"><stop stop-color="#8b5cf6" stop-opacity="0.35"/><stop offset="1" stop-color="#8b5cf6" stop-opacity="0"/></radialGradient>
6 + <radialGradient id="glow2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(200 620) scale(520 360)"><stop stop-color="#f472b6" stop-opacity="0.22"/><stop offset="1" stop-color="#f472b6" stop-opacity="0"/></radialGradient>
7 + <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="#ffffff" fill-opacity="0.08"/></pattern>
8 + </defs>
9 + <rect width="1200" height="630" fill="url(#bg)"/>
10 + <rect width="1200" height="630" fill="url(#grid)"/>
11 + <rect width="1200" height="630" fill="url(#glow)"/>
12 + <rect width="1200" height="630" fill="url(#glow2)"/>
13 + <!-- mark -->
14 + <g transform="translate(92 88)">
15 + <rect x="1" y="1" width="30" height="30" rx="9" fill="url(#g)" transform="scale(2.6)"/>
16 + <g fill="#fff" transform="scale(2.6)"><rect x="7.5" y="7" width="5" height="18" rx="2.5"/><path fill-rule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z"/><rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95"/><rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62"/><rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34"/></g>
17 + </g>
18 + <text x="196" y="150" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="46" font-weight="700" fill="#f1f2f6" letter-spacing="-1.5">Poly<tspan fill="#9a9fb1" font-weight="500">LLM</tspan></text>
19 + <text x="92" y="292" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="86" font-weight="700" fill="#f1f2f6" letter-spacing="-3.5">One interface.</text>
20 + <text x="92" y="384" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="86" font-weight="700" fill="url(#g)" letter-spacing="-3.5">Every model.</text>
21 + <text x="92" y="448" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="27" font-weight="400" fill="#9a9fb1" letter-spacing="-0.3">Bring your own keys. Compare models. Control every parameter. Track every token.</text>
22 + <!-- model strip -->
23 + <g font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="500" fill="#e5e7ef">
24 + <g transform="translate(92 508)"><rect width="196" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#d97757"/><text x="44" y="31">Claude Opus 5</text></g>
25 + <g transform="translate(302 508)"><rect width="126" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#34c99b"/><text x="44" y="31">GPT-5.5</text></g>
26 + <g transform="translate(442 508)"><rect width="188" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#6aa2ff"/><text x="44" y="31">Gemini 3.8 Pro</text></g>
27 + <g transform="translate(644 508)"><rect width="110" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#d5d8e0"/><text x="44" y="31">Grok</text></g>
28 + <g transform="translate(768 508)"><rect width="150" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#7d93ff"/><text x="44" y="31">DeepSeek</text></g>
29 + <g transform="translate(932 508)"><rect width="176" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><text x="24" y="31" fill="#9a9fb1">+ 9 providers</text></g>
30 + </g>
31 + <text x="1108" y="596" text-anchor="end" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="20" fill="#676d7e">www.polyllm.io</text>
32 +</svg>
added public/favicon-16.png +0 −0

Binary file not shown.

added public/favicon-32.png +0 −0

Binary file not shown.

added public/favicon.ico +0 −0

Binary file not shown.

added public/icon-192.png +0 −0

Binary file not shown.

added public/icon-512.png +0 −0

Binary file not shown.

added public/icon-maskable-512.png +0 −0

Binary file not shown.

modified public/icon.svg +1 −1
@@ -1 +1 @@
1 <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"><defs><linearGradient id="g" x1="4" y1="4" x2="28" y2="28" gradientUnits="userSpaceOnUse"><stop stop-color="#8b8dff"/><stop offset="0.55" stop-color="#b58cff"/><stop offset="1" stop-color="#ff8fab"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="9" fill="url(#g)"/><g stroke="#0b0c10" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 10.5h4.5M9 16h6M9 21.5h4.5"/><path d="M23 10.5h-4.5M23 16h-6M23 21.5h-4.5"/><circle cx="16" cy="16" r="2.4" fill="#0b0c10" stroke="none"/></g></svg>
1 +<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"><defs><linearGradient id="g" x1="3" y1="3" x2="29" y2="29" gradientUnits="userSpaceOnUse"><stop stop-color="#6366f1"/><stop offset="0.52" stop-color="#8b5cf6"/><stop offset="1" stop-color="#f472b6"/></linearGradient><radialGradient id="h" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9 6) rotate(48) scale(26 22)"><stop stop-color="#fff" stop-opacity="0.32"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs><rect x="1" y="1" width="30" height="30" rx="9" fill="url(#g)"/><rect x="1" y="1" width="30" height="30" rx="9" fill="url(#h)"/><g fill="#fff"><rect x="7.5" y="7" width="5" height="18" rx="2.5"/><path fill-rule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z"/><rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95"/><rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62"/><rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34"/></g></svg>
added public/og.png +0 −0

Binary file not shown.

added scripts/brand-assets.sh +20 −0
@@ -0,0 +1,20 @@
1 +#!/usr/bin/env bash
2 +# Regenerates the raster brand assets from the SVG sources (needs librsvg: `brew install librsvg`).
3 +set -euo pipefail
4 +cd "$(dirname "$0")/.."
5 +r() { rsvg-convert -w "$2" -h "$3" "$1" -o "$4"; echo " $4"; }
6 +echo "Rendering brand assets…"
7 +r public/icon.svg 32 32 public/favicon-32.png
8 +r public/icon.svg 16 16 public/favicon-16.png
9 +r public/icon.svg 180 180 public/apple-icon.png
10 +r public/icon.svg 192 192 public/icon-192.png
11 +r public/icon.svg 512 512 public/icon-512.png
12 +r public/brand/icon-maskable.svg 512 512 public/icon-maskable-512.png
13 +r public/brand/og.svg 1200 630 public/og.png
14 +# Multi-size ICO for legacy user agents (Pillow optional).
15 +python3 - <<'PY' || echo " (Pillow missing — favicon.ico skipped)"
16 +from PIL import Image
17 +im = Image.open("public/icon-512.png").convert("RGBA")
18 +im.save("public/favicon.ico", sizes=[(16,16),(32,32),(48,48),(64,64)])
19 +print(" public/favicon.ico")
20 +PY
modified src/app/api/models/route.ts +16 −6
@@ -1,19 +1,29 @@
1 1 import { z } from "zod";
2 2 import { withUser, parseBody, json } from "@/lib/api";
3 3 import { listRegistryModels, favoriteModels, recentModels, toggleFavorite } from "@/lib/ai/registry";
4 +import { modelLabels, setModelLabel } from "@/lib/ai/registry/user-models";
4 5 import { listConnections } from "@/lib/providers/keys";
5 6
6 7 export const dynamic = "force-dynamic";
7 8
8 /** GET /api/models — the normalized registry plus the caller's favorites/recents and connected providers. */
9 +/** GET /api/models — the normalized registry plus the caller's favorites/recents/labels and connected providers. */
9 10 export const GET = withUser(async ({ req, user }) => {
10 11 const includeDeprecated = new URL(req.url).searchParams.get("deprecated") === "1";
11 const [models, favorites, recents, connections] = await Promise.all([listRegistryModels({ includeDeprecated }), favoriteModels(user.id), recentModels(user.id), listConnections(user.id)]);
12 return json({ models, favorites, recents, connectedProviders: connections.filter((c) => c.status !== "invalid").map((c) => c.provider) });
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 14 });
14 15
16 +const bodySchema = z.discriminatedUnion("action", [
17 + z.object({ action: z.literal("toggle-favorite"), modelKey: z.string().max(120) }),
18 + z.object({ action: z.literal("set-label"), modelKey: z.string().max(120), label: z.string().max(60).nullable() }),
19 +]);
20 +
15 21 export const POST = withUser(async ({ req, user }) => {
16 const body = await parseBody(req, z.object({ action: z.literal("toggle-favorite"), modelKey: z.string().max(120) }));
17 const favorite = await toggleFavorite(user.id, body.modelKey);
18 return json({ favorite });
22 + const body = await parseBody(req, bodySchema);
23 + if (body.action === "toggle-favorite") {
24 + const favorite = await toggleFavorite(user.id, body.modelKey);
25 + return json({ favorite });
26 + }
27 + await setModelLabel(user.id, body.modelKey, body.label);
28 + return json({ ok: true });
19 29 });
modified src/app/globals.css +178 −41
@@ -1,21 +1,28 @@
1 1 @import "tailwindcss";
2 2
3 3 @custom-variant dark (&:where(.dark, .dark *));
4 +/* Pointer-aware variants: `coarse:` = touch devices, `fine:` = mouse/trackpad. */
5 +@custom-variant coarse (@media (pointer: coarse));
6 +@custom-variant fine (@media (pointer: fine));
7 +@custom-variant standalone (@media (display-mode: standalone));
4 8
5 9 @theme {
6 10 --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
7 11 --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
8 12
13 + /* ---- Semantic colors (values live in :root / .dark below) ---------------- */
9 14 --color-bg: var(--bg);
10 15 --color-bg-subtle: var(--bg-subtle);
11 16 --color-bg-muted: var(--bg-muted);
12 17 --color-bg-elevated: var(--bg-elevated);
13 18 --color-bg-overlay: var(--bg-overlay);
19 + --color-bg-inset: var(--bg-inset);
14 20 --color-fg: var(--fg);
15 21 --color-fg-muted: var(--fg-muted);
16 22 --color-fg-subtle: var(--fg-subtle);
17 23 --color-border: var(--border);
18 24 --color-border-strong: var(--border-strong);
25 + --color-hairline: var(--hairline);
19 26 --color-accent: var(--accent);
20 27 --color-accent-fg: var(--accent-fg);
21 28 --color-accent-soft: var(--accent-soft);
@@ -39,59 +46,116 @@
39 46 --color-kimi: var(--p-kimi);
40 47 --color-openrouter: var(--p-openrouter);
41 48 --color-cerebras: var(--p-cerebras);
49 + --color-custom: var(--p-custom);
42 50
51 + /* ---- Radii ---------------------------------------------------------------- */
43 52 --radius-xs: 4px;
44 53 --radius-sm: 6px;
45 54 --radius-md: 8px;
46 55 --radius-lg: 12px;
47 56 --radius-xl: 16px;
48 57 --radius-2xl: 22px;
58 + --radius-3xl: 28px;
49 59
50 --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.06);
51 --shadow-sm: 0 1px 3px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.04);
52 --shadow-md: 0 6px 16px rgb(0 0 0 / 0.10), 0 1px 3px rgb(0 0 0 / 0.06);
53 --shadow-lg: 0 16px 40px rgb(0 0 0 / 0.16), 0 2px 8px rgb(0 0 0 / 0.08);
54 --shadow-glow: 0 0 0 1px var(--accent-soft), 0 8px 32px -8px var(--accent);
60 + /* ---- Elevation: soft, layered, low-contrast -------------------------------- */
61 + --shadow-xs: 0 1px 2px rgb(15 17 26 / 0.05);
62 + --shadow-sm: 0 1px 2px rgb(15 17 26 / 0.06), 0 2px 6px -2px rgb(15 17 26 / 0.06);
63 + --shadow-md: 0 2px 4px -1px rgb(15 17 26 / 0.06), 0 10px 24px -8px rgb(15 17 26 / 0.12);
64 + --shadow-lg: 0 4px 8px -2px rgb(15 17 26 / 0.08), 0 24px 56px -16px rgb(15 17 26 / 0.22);
65 + --shadow-sheet: 0 -8px 40px -12px rgb(15 17 26 / 0.28);
66 + --shadow-glow: 0 0 0 1px var(--accent-soft), 0 8px 32px -8px color-mix(in srgb, var(--accent) 55%, transparent);
67 + --shadow-ring: 0 0 0 1px var(--border);
55 68
56 --animate-fade-in: fade-in 0.2s ease-out both;
57 --animate-fade-up: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
58 --animate-scale-in: scale-in 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
69 + /* ---- Type scale (fluid on small screens) ----------------------------------- */
70 + --text-2xs: 10.5px;
71 + --text-2xs--line-height: 14px;
72 + --text-xs: 11.5px;
73 + --text-xs--line-height: 16px;
74 + --text-sm: 13px;
75 + --text-sm--line-height: 18px;
76 + --text-base: 15px;
77 + --text-base--line-height: 22px;
78 + --text-lg: 17px;
79 + --text-lg--line-height: 24px;
80 + --text-xl: 20px;
81 + --text-xl--line-height: 26px;
82 + --text-2xl: 24px;
83 + --text-2xl--line-height: 30px;
84 + --text-3xl: clamp(28px, 2.4vw + 18px, 34px);
85 + --text-3xl--line-height: 1.15;
86 + --text-4xl: clamp(34px, 3.2vw + 20px, 46px);
87 + --text-4xl--line-height: 1.08;
88 + --text-5xl: clamp(40px, 4.4vw + 22px, 62px);
89 + --text-5xl--line-height: 1.02;
90 +
91 + /* ---- Motion --------------------------------------------------------------- */
92 + --ease-out-soft: cubic-bezier(0.16, 1, 0.3, 1);
93 + --ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
94 + --animate-fade-in: fade-in 0.18s ease-out both;
95 + --animate-fade-out: fade-out 0.16s ease-in both;
96 + --animate-fade-up: fade-up 0.32s var(--ease-out-soft) both;
97 + --animate-scale-in: scale-in 0.18s var(--ease-out-soft) both;
98 + --animate-scale-out: scale-out 0.14s ease-in both;
99 + --animate-sheet-in: sheet-in 0.32s var(--ease-out-soft) both;
100 + --animate-sheet-out: sheet-out 0.22s ease-in both;
101 + --animate-drawer-in: drawer-in 0.28s var(--ease-out-soft) both;
102 + --animate-drawer-out: drawer-out 0.2s ease-in both;
103 + --animate-slide-down-in: slide-down-in 0.22s var(--ease-out-soft) both;
59 104 --animate-blink: blink 1s steps(2, start) infinite;
60 105 --animate-shimmer: shimmer 1.6s linear infinite;
61 106 --animate-pulse-soft: pulse-soft 2s ease-in-out infinite;
107 + --animate-pop: pop 0.32s var(--ease-spring) both;
108 + --animate-marquee: marquee 48s linear infinite;
109 + --animate-stream-glow: stream-glow 1.8s ease-in-out infinite;
62 110
63 111 @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
112 + @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } }
64 113 @keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
65 114 @keyframes scale-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
115 + @keyframes scale-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.97); } }
116 + @keyframes sheet-in { from { transform: translateY(100%); } to { transform: translateY(0); } }
117 + @keyframes sheet-out { from { transform: translateY(0); } to { transform: translateY(100%); } }
118 + @keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }
119 + @keyframes drawer-out { from { transform: translateX(0); } to { transform: translateX(-100%); } }
120 + @keyframes slide-down-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
66 121 @keyframes blink { to { visibility: hidden; } }
67 122 @keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } }
68 123 @keyframes pulse-soft { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
124 + @keyframes pop { 0% { transform: scale(0.6); opacity: 0; } 60% { transform: scale(1.12); opacity: 1; } 100% { transform: scale(1); } }
125 + @keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
126 + @keyframes stream-glow { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); } }
69 127 }
70 128
129 +/* ---------------------------------------------------------------------------
130 + Palette — warm graphite neutrals + iris accent. Light and dark are tuned to
131 + the same perceptual contrast so components need no per-theme overrides.
132 + --------------------------------------------------------------------------- */
71 133 :root {
72 134 color-scheme: light;
73 --bg: #fafaf9;
74 --bg-subtle: #f3f3f1;
75 --bg-muted: #ebebe8;
135 + --bg: #f7f7f5;
136 + --bg-subtle: #f1f1ee;
137 + --bg-muted: #e9e9e5;
76 138 --bg-elevated: #ffffff;
77 --bg-overlay: rgb(250 250 249 / 0.85);
78 --fg: #16171d;
79 --fg-muted: #5b6070;
80 --fg-subtle: #8a8f9e;
81 --border: #e4e4e0;
82 --border-strong: #cfcfca;
83 --accent: #5b5bd6;
84 --accent-strong: #4646c4;
139 + --bg-inset: #ececea;
140 + --bg-overlay: rgb(247 247 245 / 0.82);
141 + --fg: #14151a;
142 + --fg-muted: #5c6070;
143 + --fg-subtle: #8b8f9d;
144 + --border: #e3e3df;
145 + --border-strong: #cdcdc8;
146 + --hairline: rgb(20 21 26 / 0.07);
147 + --accent: #5457d6;
148 + --accent-strong: #4144c4;
85 149 --accent-fg: #ffffff;
86 --accent-soft: #e9e9fb;
87 --success: #1f9d61;
88 --success-soft: #e3f6ea;
89 --warning: #c07a12;
90 --warning-soft: #fbf0d9;
91 --danger: #d2373f;
92 --danger-soft: #fbe5e6;
93 --info: #2a7fd4;
94 --info-soft: #e2effb;
150 + --accent-soft: #e8e8fb;
151 + --success: #1c9a5d;
152 + --success-soft: #e1f5e9;
153 + --warning: #b8720f;
154 + --warning-soft: #fbefd6;
155 + --danger: #d03540;
156 + --danger-soft: #fbe4e6;
157 + --info: #2779cf;
158 + --info-soft: #e1eefb;
95 159
96 160 --p-openai: #10a37f;
97 161 --p-anthropic: #d97757;
@@ -102,8 +166,17 @@
102 166 --p-kimi: #0f766e;
103 167 --p-openrouter: #6b46c1;
104 168 --p-cerebras: #c2410c;
169 + --p-custom: #64748b;
105 170
106 --selection: rgb(91 91 214 / 0.18);
171 + --selection: rgb(84 87 214 / 0.18);
172 + --sat: env(safe-area-inset-top, 0px);
173 + --sab: env(safe-area-inset-bottom, 0px);
174 + --sal: env(safe-area-inset-left, 0px);
175 + --sar: env(safe-area-inset-right, 0px);
176 + /* Height of the mobile bottom navigation (without the safe area). */
177 + --bottom-nav-h: 56px;
178 + /* Set by the keyboard hook: pixels hidden by the on-screen keyboard. */
179 + --kb: 0px;
107 180 }
108 181
109 182 .dark {
@@ -111,13 +184,15 @@
111 184 --bg: #0b0c10;
112 185 --bg-subtle: #10121a;
113 186 --bg-muted: #171923;
114 --bg-elevated: #14161e;
115 --bg-overlay: rgb(11 12 16 / 0.82);
116 --fg: #f2f3f7;
117 --fg-muted: #9aa0b2;
118 --fg-subtle: #6a7080;
187 + --bg-elevated: #13151d;
188 + --bg-inset: #0e0f15;
189 + --bg-overlay: rgb(11 12 16 / 0.78);
190 + --fg: #f1f2f6;
191 + --fg-muted: #9a9fb1;
192 + --fg-subtle: #676d7e;
119 193 --border: #1f222c;
120 --border-strong: #2d3140;
194 + --border-strong: #2c3040;
195 + --hairline: rgb(241 242 246 / 0.07);
121 196 --accent: #8b8dff;
122 197 --accent-strong: #a3a5ff;
123 198 --accent-fg: #0b0c10;
@@ -140,13 +215,21 @@
140 215 --p-kimi: #2dd4bf;
141 216 --p-openrouter: #a78bfa;
142 217 --p-cerebras: #fb923c;
218 + --p-custom: #9aa3b5;
143 219
144 220 --selection: rgb(139 141 255 / 0.28);
145 221 }
146 222
147 223 @layer base {
148 224 * { border-color: var(--border); }
149 html { -webkit-text-size-adjust: 100%; text-rendering: optimizeLegibility; scroll-behavior: auto; }
225 + html {
226 + -webkit-text-size-adjust: 100%;
227 + text-rendering: optimizeLegibility;
228 + scroll-behavior: auto;
229 + /* iOS: never let the root rubber-band; scroll containers own overscroll. */
230 + overscroll-behavior: none;
231 + -webkit-tap-highlight-color: transparent;
232 + }
150 233 body {
151 234 background: var(--bg);
152 235 color: var(--fg);
@@ -158,12 +241,14 @@
158 241 }
159 242 ::selection { background: var(--selection); }
160 243 :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
161 button, [role="button"] { cursor: pointer; }
244 + button, [role="button"] { cursor: pointer; touch-action: manipulation; }
162 245 button:disabled { cursor: not-allowed; }
163 246 input, textarea, select { font: inherit; }
247 + /* iOS zooms into inputs below 16px; keep them at 16px on coarse pointers. */
248 + @media (pointer: coarse) { input, textarea, select { font-size: max(16px, 1em); } }
164 249 code, kbd, pre, samp { font-family: var(--font-mono); font-feature-settings: "liga" 0; }
165 250 @media (prefers-reduced-motion: reduce) {
166 *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
251 + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
167 252 }
168 253 }
169 254
@@ -174,13 +259,18 @@
174 259 .scrollbar-none { scrollbar-width: none; }
175 260 .scrollbar-none::-webkit-scrollbar { display: none; }
176 261
177 .glass { background: var(--bg-overlay); backdrop-filter: saturate(160%) blur(14px); -webkit-backdrop-filter: saturate(160%) blur(14px); }
262 + .glass { background: var(--bg-overlay); backdrop-filter: saturate(160%) blur(16px); -webkit-backdrop-filter: saturate(160%) blur(16px); }
263 + .glass-strong { background: color-mix(in srgb, var(--bg-elevated) 88%, transparent); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); }
178 264
179 265 .text-gradient {
180 266 background: linear-gradient(120deg, var(--accent) 0%, #b58cff 55%, #ff8fab 100%);
181 267 -webkit-background-clip: text; background-clip: text; color: transparent;
182 268 }
183 269 .surface { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); }
270 + /* Soft, borderless grouping — preferred over cards for dense professional UI. */
271 + .panel { background: var(--bg-subtle); border-radius: var(--radius-xl); }
272 + .hairline-b { box-shadow: inset 0 -1px 0 var(--hairline); }
273 + .hairline-t { box-shadow: inset 0 1px 0 var(--hairline); }
184 274 .kbd {
185 275 display: inline-flex; align-items: center; justify-content: center; min-width: 1.4rem; height: 1.25rem; padding: 0 0.35rem;
186 276 border-radius: 5px; border: 1px solid var(--border-strong); background: var(--bg-subtle); color: var(--fg-muted);
@@ -195,10 +285,47 @@
195 285 background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
196 286 background-size: 22px 22px;
197 287 }
288 + /* Minimum 44px hit area regardless of the visual size. */
289 + .tap { position: relative; }
290 + .tap::after { content: ""; position: absolute; inset: 50% auto auto 50%; width: max(100%, 44px); height: max(100%, 44px); transform: translate(-50%, -50%); }
291 + /* Safe-area helpers */
292 + .pt-safe { padding-top: var(--sat); }
293 + .pb-safe { padding-bottom: var(--sab); }
294 + .pl-safe { padding-left: var(--sal); }
295 + .pr-safe { padding-right: var(--sar); }
296 + .px-safe { padding-left: var(--sal); padding-right: var(--sar); }
297 + .mb-safe { margin-bottom: var(--sab); }
298 + /* Space reserved for the mobile bottom navigation. */
299 + .pb-nav { padding-bottom: calc(var(--bottom-nav-h) + var(--sab)); }
300 + /* Full app height that tracks the on-screen keyboard (set by useKeyboardInset). */
301 + .h-app { height: calc(100dvh - var(--kb)); }
302 + .min-h-app { min-height: calc(100dvh - var(--kb)); }
303 + /* Sheet drag handle */
304 + .sheet-handle { width: 36px; height: 5px; border-radius: 999px; background: var(--border-strong); margin: 8px auto 4px; }
305 + /* Horizontal snap carousel (Arena / compare panels on mobile) */
306 + .snap-row { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
307 + .snap-row::-webkit-scrollbar { display: none; }
308 + .snap-row > * { scroll-snap-align: center; scroll-snap-stop: always; flex: 0 0 100%; }
309 + /* Hide only the scroll chaining, keep the scroll */
310 + .contain-scroll { overscroll-behavior: contain; }
311 + .touch-pan-y { touch-action: pan-y; }
312 + .touch-pan-x { touch-action: pan-x; }
313 + /* Hover-revealed controls: always visible on touch devices. */
314 + .hover-reveal { opacity: 0; transition: opacity 150ms; }
315 + .group:hover .hover-reveal, .group:focus-within .hover-reveal, .hover-reveal:focus-visible { opacity: 1; }
316 + @media (pointer: coarse) { .hover-reveal { opacity: 1; } }
317 + /* Marquee strip */
318 + .marquee { display: flex; width: max-content; animation: var(--animate-marquee); }
319 + .marquee:hover { animation-play-state: paused; }
320 + .mask-x { mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
321 + .mask-b { mask-image: linear-gradient(180deg, #000 70%, transparent); }
322 + .tabular { font-variant-numeric: tabular-nums; }
323 + .text-balance { text-wrap: balance; }
324 + .text-pretty { text-wrap: pretty; }
198 325 }
199 326
200 327 /* Markdown (chat messages) */
201 .prose-chat { line-height: 1.65; font-size: 15px; word-break: break-word; }
328 +.prose-chat { line-height: 1.65; font-size: 15px; word-break: break-word; overflow-wrap: anywhere; }
202 329 .prose-chat > * + * { margin-top: 0.75em; }
203 330 .prose-chat h1, .prose-chat h2, .prose-chat h3, .prose-chat h4 { font-weight: 650; letter-spacing: -0.01em; line-height: 1.3; margin-top: 1.2em; }
204 331 .prose-chat h1 { font-size: 1.35em; } .prose-chat h2 { font-size: 1.2em; } .prose-chat h3 { font-size: 1.08em; } .prose-chat h4 { font-size: 1em; }
@@ -213,12 +340,22 @@
213 340 .prose-chat hr { border: 0; border-top: 1px solid var(--border); margin: 1.2em 0; }
214 341 .prose-chat :not(pre) > code { font-size: 0.86em; background: var(--bg-muted); border: 1px solid var(--border); padding: 0.1em 0.38em; border-radius: 5px; }
215 342 .prose-chat pre { margin: 0; }
216 .prose-chat table { width: 100%; border-collapse: collapse; font-size: 0.92em; display: block; overflow-x: auto; }
343 +.prose-chat table { width: 100%; border-collapse: collapse; font-size: 0.92em; display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
217 344 .prose-chat th, .prose-chat td { border: 1px solid var(--border); padding: 0.4em 0.7em; text-align: left; vertical-align: top; }
218 345 .prose-chat th { background: var(--bg-subtle); font-weight: 600; }
219 346 .prose-chat img { max-width: 100%; border-radius: 8px; }
220 347 .prose-chat.wrap-code pre code { white-space: pre-wrap; word-break: break-word; }
348 +.prose-chat .katex-display { overflow-x: auto; overflow-y: hidden; padding: 0.2em 0; }
221 349
222 350 /* Shiki */
223 351 .shiki, .shiki span { background-color: transparent !important; }
224 352 .dark .shiki, .dark .shiki span { color: var(--shiki-dark) !important; }
353 +
354 +/* Recharts: inherit the type system */
355 +.recharts-text { font-family: var(--font-sans); }
356 +.recharts-default-tooltip { border-radius: 10px !important; border: 1px solid var(--border) !important; background: var(--bg-elevated) !important; box-shadow: var(--shadow-md); }
357 +
358 +/* Standalone PWA: reserve the status bar area */
359 +@media (display-mode: standalone) {
360 + body { padding-top: 0; }
361 +}
modified src/app/layout.tsx +21 −5
@@ -17,24 +17,40 @@ export const metadata: Metadata = {
17 17 openGraph: {
18 18 type: "website",
19 19 siteName: "PolyLLM",
20 title: "PolyLLM — Your models. Your keys. One workspace.",
21 description: "A universal control center for AI models. Bring your own keys.",
20 + title: "PolyLLM — One interface. Every model.",
21 + description: "Bring your own keys. Compare models. Control every parameter. Track every token. Your models. Your keys. One workspace.",
22 + locale: "en_US",
22 23 url: APP_URL,
23 24 images: [{ url: "/og.png", width: 1200, height: 630, alt: "PolyLLM" }],
24 25 },
25 twitter: { card: "summary_large_image", title: "PolyLLM", description: "One interface. Every model. Bring your own keys." },
26 icons: { icon: [{ url: "/icon.svg", type: "image/svg+xml" }], apple: "/apple-icon.png" },
26 + twitter: { card: "summary_large_image", title: "PolyLLM — One interface. Every model.", description: "Bring your own keys. Compare models. Control every parameter. Track every token.", images: ["/og.png"] },
27 + authors: [{ name: "Simon-Pierre Boucher", url: "https://www.maclustr.io" }],
28 + creator: "Simon-Pierre Boucher",
29 + icons: {
30 + icon: [
31 + { url: "/icon.svg", type: "image/svg+xml" },
32 + { url: "/favicon-32.png", sizes: "32x32", type: "image/png" },
33 + { url: "/favicon-16.png", sizes: "16x16", type: "image/png" },
34 + ],
35 + shortcut: "/favicon.ico",
36 + apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
37 + },
38 + manifest: "/manifest.webmanifest",
39 + appleWebApp: { capable: true, title: "PolyLLM", statusBarStyle: "black-translucent" },
40 + formatDetection: { telephone: false },
27 41 robots: { index: true, follow: true },
28 42 };
29 43
30 44 export const viewport: Viewport = {
31 45 themeColor: [
32 { media: "(prefers-color-scheme: light)", color: "#fafaf9" },
46 + { media: "(prefers-color-scheme: light)", color: "#f7f7f5" },
33 47 { media: "(prefers-color-scheme: dark)", color: "#0b0c10" },
34 48 ],
35 49 width: "device-width",
36 50 initialScale: 1,
37 51 viewportFit: "cover",
52 + // Let the browser resize the layout viewport when the keyboard opens (Chrome/Android); iOS uses visualViewport.
53 + interactiveWidget: "resizes-content",
38 54 };
39 55
40 56 export default function RootLayout({ children }: { children: React.ReactNode }) {
added src/app/manifest.ts +30 −0
@@ -0,0 +1,30 @@
1 +import type { MetadataRoute } from "next";
2 +
3 +export default function manifest(): MetadataRoute.Manifest {
4 + return {
5 + name: "PolyLLM — One interface. Every model.",
6 + short_name: "PolyLLM",
7 + description: "Bring your own keys. Compare models. Control every parameter. Track every token.",
8 + id: "/app/chat",
9 + start_url: "/app/chat",
10 + scope: "/",
11 + display: "standalone",
12 + display_override: ["standalone", "minimal-ui"],
13 + orientation: "portrait",
14 + background_color: "#0b0c10",
15 + theme_color: "#0b0c10",
16 + lang: "en",
17 + categories: ["productivity", "developer tools", "utilities"],
18 + icons: [
19 + { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
20 + { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
21 + { src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
22 + ],
23 + shortcuts: [
24 + { name: "New chat", url: "/app/chat", icons: [{ src: "/icon-192.png", sizes: "192x192" }] },
25 + { name: "Arena", url: "/app/arena", icons: [{ src: "/icon-192.png", sizes: "192x192" }] },
26 + { name: "Models", url: "/app/models", icons: [{ src: "/icon-192.png", sizes: "192x192" }] },
27 + ],
28 + screenshots: [{ src: "/og.png", sizes: "1200x630", type: "image/png", form_factor: "wide" }],
29 + };
30 +}
added src/components/app/bottom-nav.tsx +52 −0
@@ -0,0 +1,52 @@
1 +"use client";
2 +import * as React from "react";
3 +import Link from "next/link";
4 +import { usePathname } from "next/navigation";
5 +import { BarChart3, Boxes, MessageSquare, Swords, UserRound } from "lucide-react";
6 +import { cn } from "@/lib/utils";
7 +
8 +export const MOBILE_TABS = [
9 + { href: "/app/chat", label: "Chat", icon: MessageSquare, match: /^\/app\/(chat|projects|prompts|presets)/ },
10 + { href: "/app/arena", label: "Arena", icon: Swords, match: /^\/app\/arena/ },
11 + { href: "/app/models", label: "Models", icon: Boxes, match: /^\/app\/models/ },
12 + { href: "/app/usage", label: "Usage", icon: BarChart3, match: /^\/app\/usage/ },
13 + { href: "/app/settings/account", label: "Account", icon: UserRound, match: /^\/app\/settings/ },
14 +] as const;
15 +
16 +/**
17 + * Persistent bottom navigation for phones. Hidden automatically while the on-screen keyboard is open
18 + * (`html[data-keyboard]`) so the composer sits directly above the keyboard.
19 + */
20 +export function BottomNav({ className }: { className?: string }) {
21 + const pathname = usePathname();
22 + return (
23 + <nav
24 + aria-label="Primary"
25 + className={cn(
26 + "glass-strong fixed inset-x-0 bottom-0 z-30 border-t border-hairline md:hidden",
27 + "pb-[var(--sab)] transition-transform duration-200 ease-out [html[data-keyboard]_&]:translate-y-full",
28 + className,
29 + )}
30 + >
31 + <ul className="mx-auto grid h-[var(--bottom-nav-h)] max-w-lg grid-cols-5">
32 + {MOBILE_TABS.map((t) => {
33 + const active = t.match.test(pathname);
34 + return (
35 + <li key={t.href} className="min-w-0">
36 + <Link
37 + href={t.href}
38 + aria-current={active ? "page" : undefined}
39 + className={cn("flex h-full flex-col items-center justify-center gap-0.5 text-[10.5px] font-medium tracking-tight transition-colors", active ? "text-fg" : "text-fg-subtle active:text-fg-muted")}
40 + >
41 + <span className={cn("flex h-7 w-12 items-center justify-center rounded-full transition-[background-color,transform] duration-200", active ? "bg-accent-soft text-accent" : "")}>
42 + <t.icon className={cn("size-[21px]", active && "scale-105")} strokeWidth={active ? 2.2 : 1.8} />
43 + </span>
44 + {t.label}
45 + </Link>
46 + </li>
47 + );
48 + })}
49 + </ul>
50 + </nav>
51 + );
52 +}
modified src/components/app/shell.tsx +76 −23
@@ -5,36 +5,41 @@ import { PanelLeft } from "lucide-react";
5 5 import { useApp } from "./store";
6 6 import { Sidebar } from "./sidebar";
7 7 import { CommandPalette } from "./command-palette";
8 +import { BottomNav } from "./bottom-nav";
8 9 import { Button } from "@/components/ui/button";
10 +import { useIsMobile, useKeyboardInset, useLocalStorage, usePrefersReducedMotion, useSwipe } from "@/lib/client/hooks";
9 11 import { cn } from "@/lib/utils";
10 12
11 13 const LS_COLLAPSED = "polyllm:sidebar-collapsed";
12 14
15 +/**
16 + * Application shell.
17 + *
18 + * Desktop (≥ md): persistent left sidebar (collapsible, ⌘B) + content.
19 + * Phone: full-viewport content (`.h-app` tracks the keyboard), a slide-over conversation drawer
20 + * (edge-swipe to open, swipe left / tap scrim to close) and a persistent bottom navigation.
21 + */
13 22 export function AppShell({ children }: { children: React.ReactNode }) {
14 23 const { sidebarOpen, setSidebarOpen, setPaletteOpen, paletteOpen } = useApp();
15 const [collapsed, setCollapsed] = React.useState(false);
24 + const [collapsed, setCollapsed] = useLocalStorage<boolean>(LS_COLLAPSED, false);
16 25 const router = useRouter();
17 26 const pathname = usePathname();
27 + const isMobile = useIsMobile();
28 + useKeyboardInset();
18 29
19 React.useEffect(() => {
20 // eslint-disable-next-line react-hooks/set-state-in-effect
21 setCollapsed(window.localStorage.getItem(LS_COLLAPSED) === "1");
22 }, []);
30 + // Close the drawer on navigation.
23 31 React.useEffect(() => {
24 32 setSidebarOpen(false);
25 33 }, [pathname, setSidebarOpen]);
26 34
27 const toggleCollapsed = React.useCallback(() => {
28 setCollapsed((c) => {
29 window.localStorage.setItem(LS_COLLAPSED, c ? "0" : "1");
30 return !c;
31 });
32 }, []);
35 + const toggleCollapsed = React.useCallback(() => setCollapsed((c) => !c), [setCollapsed]);
33 36
34 37 // Global shortcuts
35 38 React.useEffect(() => {
36 39 const onKey = (e: KeyboardEvent) => {
37 40 const mod = e.metaKey || e.ctrlKey;
41 + const target = e.target as HTMLElement | null;
42 + const typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
38 43 if (mod && e.key.toLowerCase() === "k") {
39 44 e.preventDefault();
40 45 setPaletteOpen(!paletteOpen);
@@ -44,30 +49,34 @@ export function AppShell({ children }: { children: React.ReactNode }) {
44 49 } else if (mod && e.key === "b") {
45 50 e.preventDefault();
46 51 toggleCollapsed();
52 + } else if (!mod && !typing && e.key === "?" ) {
53 + e.preventDefault();
54 + setPaletteOpen(true);
47 55 }
48 56 };
49 57 window.addEventListener("keydown", onKey);
50 58 return () => window.removeEventListener("keydown", onKey);
51 59 }, [paletteOpen, router, setPaletteOpen, toggleCollapsed]);
52 60
61 + // Edge swipe (from the left 24px) opens the drawer on phones.
62 + const contentRef = React.useRef<HTMLDivElement>(null);
63 + const openDrawer = React.useCallback(() => setSidebarOpen(true), [setSidebarOpen]);
64 + useSwipe(contentRef, { onSwipeRight: openDrawer, edge: 28, disabled: !isMobile || sidebarOpen, ignoreSelector: ".snap-row, [data-no-edge-swipe]" });
65 +
66 + // Hide the bottom nav on full-screen surfaces (e.g. share preview) via a data attribute set by pages.
67 + const showBottomNav = isMobile && !pathname.startsWith("/app/onboarding");
68 +
53 69 return (
54 <div className="flex h-dvh w-full overflow-hidden bg-bg">
70 + <div className="flex h-app w-full overflow-hidden bg-bg">
55 71 {/* Desktop sidebar */}
56 <aside className={cn("hidden md:flex h-full shrink-0 flex-col border-r border-border bg-bg-subtle transition-[width] duration-200 ease-out", collapsed ? "w-0 overflow-hidden border-r-0" : "w-[272px]")}>
72 + <aside className={cn("hidden md:flex h-full shrink-0 flex-col border-r border-border bg-bg-subtle transition-[width] duration-200 ease-out", collapsed ? "w-0 overflow-hidden border-r-0" : "w-[276px]")}>
57 73 <Sidebar onCollapse={toggleCollapsed} />
58 74 </aside>
59 75
60 76 {/* Mobile drawer */}
61 {sidebarOpen ? (
62 <div className="fixed inset-0 z-40 md:hidden">
63 <div className="absolute inset-0 bg-black/50 animate-fade-in" onClick={() => setSidebarOpen(false)} />
64 <aside className="absolute inset-y-0 left-0 flex w-[86vw] max-w-[320px] flex-col border-r border-border bg-bg-subtle shadow-lg animate-fade-in">
65 <Sidebar onCollapse={() => setSidebarOpen(false)} mobile />
66 </aside>
67 </div>
68 ) : null}
69
70 <div className="relative flex min-w-0 flex-1 flex-col">
77 + <MobileDrawer open={sidebarOpen && isMobile} onClose={() => setSidebarOpen(false)} />
78 +
79 + <div ref={contentRef} className={cn("relative flex min-w-0 flex-1 flex-col", showBottomNav && "pb-[calc(var(--bottom-nav-h)+var(--sab))] [html[data-keyboard]_&]:pb-0")}>
71 80 {collapsed ? (
72 81 <Button variant="ghost" size="icon-sm" className="absolute left-2 top-2 z-20 hidden md:inline-flex" onClick={toggleCollapsed} aria-label="Show sidebar">
73 82 <PanelLeft />
@@ -75,7 +84,51 @@ export function AppShell({ children }: { children: React.ReactNode }) {
75 84 ) : null}
76 85 {children}
77 86 </div>
87 +
88 + {showBottomNav ? <BottomNav /> : null}
78 89 <CommandPalette />
79 90 </div>
80 91 );
81 92 }
93 +
94 +function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
95 + const reduced = usePrefersReducedMotion();
96 + const [render, setRender] = React.useState(open);
97 + const [closing, setClosing] = React.useState(false);
98 + const panelRef = React.useRef<HTMLDivElement>(null);
99 +
100 + React.useEffect(() => {
101 + if (open) {
102 + // eslint-disable-next-line react-hooks/set-state-in-effect
103 + setRender(true);
104 + setClosing(false);
105 + } else if (render) {
106 + setClosing(true);
107 + const t = setTimeout(() => {
108 + setRender(false);
109 + setClosing(false);
110 + }, reduced ? 0 : 200);
111 + return () => clearTimeout(t);
112 + }
113 + }, [open, render, reduced]);
114 +
115 + useSwipe(panelRef, { onSwipeLeft: onClose, disabled: !open });
116 +
117 + // Lock body scroll and close on Escape while open.
118 + React.useEffect(() => {
119 + if (!open) return;
120 + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
121 + window.addEventListener("keydown", onKey);
122 + return () => window.removeEventListener("keydown", onKey);
123 + }, [open, onClose]);
124 +
125 + if (!render) return null;
126 + return (
127 + <div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true" aria-label="Conversations">
128 + <div className={cn("absolute inset-0 bg-black/45 backdrop-blur-[2px]", closing ? "animate-fade-out" : "animate-fade-in")} onClick={onClose} />
129 + <div ref={panelRef} className={cn("absolute inset-y-0 left-0 flex w-[min(86vw,340px)] flex-col bg-bg-subtle shadow-lg pt-[var(--sat)] pb-[var(--sab)]", closing ? "animate-drawer-out" : "animate-drawer-in")}>
130 + <Sidebar onCollapse={onClose} mobile />
131 + </div>
132 + </div>
133 + );
134 +}
modified src/components/app/sidebar.tsx +155 −108
@@ -2,18 +2,22 @@
2 2 import * as React from "react";
3 3 import Link from "next/link";
4 4 import { usePathname, useRouter } from "next/navigation";
5 import { Archive, BarChart3, Boxes, ChevronDown, ChevronRight, Copy, Download, Folder as FolderIcon, FolderPlus, MessageSquare, MoreHorizontal, PanelLeftClose, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Sparkles, Swords, Trash2, WandSparkles, X, Share2, LogOut } from "lucide-react";
5 +import { Archive, BarChart3, Boxes, ChevronDown, ChevronRight, Copy, Download, Folder as FolderIcon, FolderPlus, MessageSquare, MoreHorizontal, PanelLeftClose, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Sparkles, Swords, Trash2, WandSparkles, X, Share2, LogOut, Library, BookMarked } from "lucide-react";
6 6 import { useApp, invalidateConversations } from "./store";
7 7 import { useApi, api } from "@/lib/client/api";
8 8 import type { ConversationsResponse, PublicConversation } from "@/lib/client/types";
9 9 import { Logo } from "@/components/brand/logo";
10 10 import { ProviderIcon } from "@/components/brand/provider-icon";
11 11 import { Button } from "@/components/ui/button";
12 import { Input } from "@/components/ui/input";
13 12 import { Kbd, Skeleton } from "@/components/ui/misc";
14 13 import { Tooltip } from "@/components/ui/tooltip";
15 14 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from "@/components/ui/dropdown-menu";
15 +import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";
16 +import { PromptDialog } from "@/components/common/prompt-dialog";
17 +import { ConfirmDialog } from "@/components/common/confirm-dialog";
18 +import { ProjectsSidebarSection } from "@/components/projects/sidebar-section";
16 19 import { toast } from "@/components/ui/toast";
20 +import { useIsMobile, useLongPress } from "@/lib/client/hooks";
17 21 import { cn } from "@/lib/utils";
18 22 import { signOut } from "@/lib/auth-client";
19 23
@@ -23,20 +27,29 @@ const NAV = [
23 27 { href: "/app/models", label: "Models", icon: Boxes, match: /^\/app\/models/ },
24 28 { href: "/app/prompts", label: "Prompts", icon: WandSparkles, match: /^\/app\/prompts/ },
25 29 { href: "/app/presets", label: "Presets", icon: Sparkles, match: /^\/app\/presets/ },
30 + { href: "/app/library", label: "Library", icon: Library, match: /^\/app\/library/ },
26 31 { href: "/app/usage", label: "Usage", icon: BarChart3, match: /^\/app\/usage/ },
27 32 ];
28 33
34 +type Modal = { kind: "rename"; c: PublicConversation } | { kind: "delete"; c: PublicConversation } | { kind: "new-folder" } | { kind: "delete-folder"; id: string; name: string } | null;
35 +
36 +/**
37 + * Conversation sidebar. Desktop: persistent column. Phone (`mobile`): content of the slide-over drawer
38 + * with 44px rows, long-press actions (ActionSheet) and no browser `prompt()`/`confirm()` dialogs.
39 + */
29 40 export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile?: boolean }) {
30 const { user, folders, refreshFolders, setPaletteOpen } = useApp();
41 + const { user, folders, refreshFolders, setPaletteOpen, setSearchOpen } = useApp();
31 42 const pathname = usePathname();
32 43 const router = useRouter();
44 + const isMobile = useIsMobile();
33 45 const [q, setQ] = React.useState("");
34 46 const [showArchived, setShowArchived] = React.useState(false);
47 + const [modal, setModal] = React.useState<Modal>(null);
48 + const [sheetFor, setSheetFor] = React.useState<PublicConversation | null>(null);
35 49 const key = `/api/conversations?limit=120${showArchived ? "&archived=1" : ""}${q ? `&q=${encodeURIComponent(q)}` : ""}`;
36 50 const { data, isLoading, mutate } = useApi<ConversationsResponse>(key, { keepPreviousData: true });
37 51 const list = React.useMemo(() => data?.conversations ?? [], [data]);
38 52 const activeId = pathname.match(/^\/app\/chat\/([^/]+)/)?.[1];
39
40 53 const groups = React.useMemo(() => groupConversations(list, folders.map((f) => ({ id: f.id, name: f.name }))), [list, folders]);
41 54
42 55 const patch = async (id: string, body: Record<string, unknown>) => {
@@ -44,26 +57,18 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
44 57 await mutate();
45 58 invalidateConversations();
46 59 };
47
48 60 const onDelete = async (c: PublicConversation) => {
49 if (!confirm(`Delete "${c.title}"? This cannot be undone.`)) return;
50 61 await api(`/api/conversations/${c.id}`, { method: "DELETE" });
51 62 await mutate();
63 + invalidateConversations();
52 64 if (activeId === c.id) router.push("/app/chat");
53 65 toast.success("Conversation deleted");
54 66 };
55
56 const onRename = async (c: PublicConversation) => {
57 const title = prompt("Rename conversation", c.title);
58 if (title && title.trim() && title !== c.title) await patch(c.id, { title: title.trim() });
59 };
60
61 67 const onDuplicate = async (c: PublicConversation) => {
62 68 const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "duplicate" } });
63 69 await mutate();
64 70 router.push(`/app/chat/${res.conversation.id}`);
65 71 };
66
67 72 const onExport = async (c: PublicConversation, format: "json" | "markdown") => {
68 73 const res = await fetch(`/api/conversations/${c.id}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "export", format }) });
69 74 const blob = await res.blob();
@@ -73,7 +78,6 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
73 78 a.click();
74 79 URL.revokeObjectURL(a.href);
75 80 };
76
77 81 const onShare = async (c: PublicConversation) => {
78 82 const res = await api<{ id: string }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "share" } });
79 83 const url = `${window.location.origin}/share/${res.id}`;
@@ -81,48 +85,67 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
81 85 toast.success("Share link copied", url);
82 86 };
83 87
84 const onNewFolder = async () => {
85 const name = prompt("Folder name");
86 if (!name?.trim()) return;
87 await api("/api/folders", { method: "POST", json: { name: name.trim() } });
88 await refreshFolders();
89 };
88 + const rowActions = (c: PublicConversation) => ({
89 + onRename: () => setModal({ kind: "rename", c }),
90 + onPin: () => patch(c.id, { pinned: !c.pinned }),
91 + onArchive: () => patch(c.id, { archived: !c.archived }),
92 + onDelete: () => setModal({ kind: "delete", c }),
93 + onDuplicate: () => onDuplicate(c),
94 + onExport: (f: "json" | "markdown") => onExport(c, f),
95 + onShare: () => onShare(c),
96 + onMove: (folderId: string | null) => patch(c.id, { folderId }),
97 + });
90 98
91 const onDeleteFolder = async (id: string, name: string) => {
92 if (!confirm(`Delete folder "${name}"? Conversations inside are kept.`)) return;
93 await api(`/api/folders?id=${id}`, { method: "DELETE" });
94 await Promise.all([refreshFolders(), mutate()]);
99 + const sheetItems = (c: PublicConversation): (ActionSheetItem | "separator")[] => {
100 + const a = rowActions(c);
101 + return [
102 + { key: "rename", label: "Rename", icon: <Pencil />, onSelect: a.onRename },
103 + { key: "pin", label: c.pinned ? "Unpin" : "Pin", icon: c.pinned ? <PinOff /> : <Pin />, onSelect: a.onPin },
104 + { key: "share", label: "Copy share link", icon: <Share2 />, onSelect: a.onShare },
105 + { key: "dup", label: "Duplicate", icon: <Copy />, onSelect: a.onDuplicate },
106 + { key: "md", label: "Export Markdown", icon: <Download />, onSelect: () => a.onExport("markdown") },
107 + ...(folders.length ? ([{ key: "nofolder", label: "Remove from folder", icon: <FolderIcon />, onSelect: () => a.onMove(null), disabled: !c.folderId }] as ActionSheetItem[]) : []),
108 + ...folders.map<ActionSheetItem>((f) => ({ key: `f-${f.id}`, label: `Move to ${f.name}`, icon: <FolderIcon />, onSelect: () => a.onMove(f.id), selected: c.folderId === f.id })),
109 + "separator",
110 + { key: "archive", label: c.archived ? "Unarchive" : "Archive", icon: <Archive />, onSelect: a.onArchive },
111 + { key: "delete", label: "Delete", icon: <Trash2 />, onSelect: a.onDelete, destructive: true },
112 + ];
95 113 };
96 114
97 115 return (
98 116 <div className="flex h-full flex-col">
99 <div className="flex items-center justify-between px-3 pt-3 pb-2">
117 + <div className="flex items-center justify-between px-3 pb-1 pt-3">
100 118 <Link href="/app/chat" className="rounded-md px-1 py-0.5 hover:bg-bg-muted">
101 119 <Logo size={22} />
102 120 </Link>
103 121 <Tooltip content={mobile ? "Close" : "Collapse sidebar ⌘B"}>
104 <Button variant="ghost" size="icon-sm" onClick={onCollapse} aria-label="Collapse sidebar">
122 + <Button variant="ghost" size={mobile ? "icon" : "icon-sm"} onClick={onCollapse} aria-label={mobile ? "Close" : "Collapse sidebar"}>
105 123 {mobile ? <X /> : <PanelLeftClose />}
106 124 </Button>
107 125 </Tooltip>
108 126 </div>
109 127
110 <div className="px-3 pb-2">
111 <Button asChild variant="primary" className="w-full justify-between">
128 + <div className="flex gap-1.5 px-3 pb-2 pt-1">
129 + <Button asChild variant="primary" size={mobile ? "lg" : "md"} className={cn("flex-1 justify-between", mobile ? "rounded-xl text-[15px]" : "")}>
112 130 <Link href="/app/chat">
113 131 <span className="inline-flex items-center gap-2">
114 132 <Plus className="size-4" /> New chat
115 133 </span>
116 <Kbd className="bg-transparent border-border-strong/40 text-bg/70">⌘N</Kbd>
134 + {!mobile ? <Kbd className="border-border-strong/40 bg-transparent text-bg/70">⌘N</Kbd> : null}
117 135 </Link>
118 136 </Button>
137 + <Tooltip content="Search (⌘K)">
138 + <Button variant="outline" size={mobile ? "icon-lg" : "icon"} onClick={() => (isMobile ? setSearchOpen(true) : setPaletteOpen(true))} aria-label="Search conversations">
139 + <Search />
140 + </Button>
141 + </Tooltip>
119 142 </div>
120 143
121 144 <nav className="px-2 pb-1">
122 145 {NAV.map((n) => {
123 146 const active = n.match.test(pathname);
124 147 return (
125 <Link key={n.href} href={n.href} className={cn("flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13.5px] font-medium transition-colors", active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}>
148 + <Link key={n.href} href={n.href} className={cn("flex items-center gap-2.5 rounded-md px-2.5 text-[13.5px] font-medium transition-colors", mobile ? "min-h-[42px] text-[15px]" : "py-1.5", active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}>
126 149 <n.icon className={cn("size-4", active ? "text-accent" : "text-fg-subtle")} />
127 150 {n.label}
128 151 </Link>
@@ -130,32 +153,31 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
130 153 })}
131 154 </nav>
132 155
133 <div className="mx-3 my-1.5 flex items-center gap-1.5">
134 <div className="relative flex-1">
135 <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" />
136 <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search chats" className="h-8 pl-7 text-[13px] bg-bg" aria-label="Search conversations" />
156 + <div className="mx-3 my-1.5">
157 + <div className="relative">
158 + <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" />
159 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Filter chats" className={cn("w-full rounded-md border border-border bg-bg pl-8 pr-2 text-[13px] outline-none placeholder:text-fg-subtle focus:border-accent", mobile ? "h-10 text-[15px]" : "h-8")} aria-label="Filter conversations" />
137 160 </div>
138 <Tooltip content="Command palette ⌘K">
139 <Button variant="ghost" size="icon-sm" onClick={() => setPaletteOpen(true)} aria-label="Command palette">
140 <Kbd>⌘K</Kbd>
141 </Button>
142 </Tooltip>
143 161 </div>
144 162
145 <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2 scrollbar-thin">
163 + <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2 scrollbar-thin contain-scroll">
164 + <ProjectsSidebarSection onNavigate={mobile ? onCollapse : undefined} />
146 165 {isLoading && !data ? (
147 <div className="space-y-1.5 px-1 pt-2">
166 + <div className="space-y-1.5 px-1 pt-3">
148 167 {Array.from({ length: 8 }).map((_, i) => (
149 <Skeleton key={i} className="h-7" />
168 + <Skeleton key={i} className={mobile ? "h-9" : "h-7"} />
150 169 ))}
151 170 </div>
152 171 ) : list.length === 0 ? (
153 <p className="px-2 pt-6 text-center text-xs text-fg-subtle">{q ? "No conversations match." : showArchived ? "No archived conversations." : "No conversations yet. Start a new chat."}</p>
172 + <div className="px-2 pt-8 text-center">
173 + <BookMarked className="mx-auto size-5 text-fg-subtle" />
174 + <p className="mt-2 text-xs text-fg-subtle">{q ? "No conversations match." : showArchived ? "No archived conversations." : "No conversations yet. Start a new chat."}</p>
175 + </div>
154 176 ) : (
155 177 groups.map((g) => (
156 <Group key={g.key} title={g.title} count={g.items.length} icon={g.folderId ? <FolderIcon className="size-3.5" /> : undefined} defaultOpen={g.key !== "older"} onDeleteFolder={g.folderId ? () => onDeleteFolder(g.folderId!, g.title) : undefined}>
178 + <Group key={g.key} title={g.title} count={g.items.length} icon={g.folderId ? <FolderIcon className="size-3.5" /> : undefined} defaultOpen={g.key !== "older"} onDeleteFolder={g.folderId ? () => setModal({ kind: "delete-folder", id: g.folderId!, name: g.title }) : undefined}>
157 179 {g.items.map((c) => (
158 <ConversationRow key={c.id} c={c} active={c.id === activeId} folders={folders} onRename={() => onRename(c)} onPin={() => patch(c.id, { pinned: !c.pinned })} onArchive={() => patch(c.id, { archived: !c.archived })} onDelete={() => onDelete(c)} onDuplicate={() => onDuplicate(c)} onExport={(f) => onExport(c, f)} onShare={() => onShare(c)} onMove={(folderId) => patch(c.id, { folderId })} />
180 + <ConversationRow key={c.id} c={c} active={c.id === activeId} mobile={Boolean(mobile)} folders={folders} onLongPress={() => setSheetFor(c)} {...rowActions(c)} />
159 181 ))}
160 182 </Group>
161 183 ))
@@ -164,18 +186,18 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
164 186
165 187 <div className="border-t border-border px-2 py-2">
166 188 <div className="flex items-center justify-between px-1 pb-1">
167 <button onClick={() => setShowArchived((v) => !v)} className={cn("inline-flex items-center gap-1.5 rounded px-1.5 py-1 text-[12px] text-fg-subtle hover:text-fg", showArchived && "text-fg")}>
189 + <button onClick={() => setShowArchived((v) => !v)} className={cn("tap inline-flex items-center gap-1.5 rounded px-1.5 py-1 text-[12px] text-fg-subtle hover:text-fg", showArchived && "text-fg")}>
168 190 <Archive className="size-3.5" /> {showArchived ? "Hide archived" : "Archived"}
169 191 </button>
170 192 <Tooltip content="New folder">
171 <Button variant="ghost" size="icon-sm" onClick={onNewFolder} aria-label="New folder">
193 + <Button variant="ghost" size="icon-sm" onClick={() => setModal({ kind: "new-folder" })} aria-label="New folder">
172 194 <FolderPlus />
173 195 </Button>
174 196 </Tooltip>
175 197 </div>
176 198 <DropdownMenu>
177 199 <DropdownMenuTrigger asChild>
178 <button className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-bg-muted">
200 + <button className={cn("flex w-full items-center gap-2.5 rounded-md px-2 text-left hover:bg-bg-muted", mobile ? "min-h-[48px]" : "py-1.5")}>
179 201 <span className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-[12px] font-semibold text-accent">{(user.name || user.email).slice(0, 1).toUpperCase()}</span>
180 202 <span className="min-w-0 flex-1">
181 203 <span className="block truncate text-[13px] font-medium">{user.name || user.email.split("@")[0]}</span>
@@ -208,6 +230,15 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
208 230 </DropdownMenuContent>
209 231 </DropdownMenu>
210 232 </div>
233 +
234 + {/* Mobile long-press actions */}
235 + <ActionSheet open={Boolean(sheetFor)} onOpenChange={(o) => !o && setSheetFor(null)} title={sheetFor?.title} items={sheetFor ? sheetItems(sheetFor) : []} />
236 +
237 + {/* Dialogs (replace window.prompt/confirm) */}
238 + <PromptDialog open={modal?.kind === "rename"} onOpenChange={(o) => !o && setModal(null)} title="Rename conversation" defaultValue={modal?.kind === "rename" ? modal.c.title : ""} onSubmit={async (v) => { if (modal?.kind === "rename") await patch(modal.c.id, { title: v }); }} />
239 + <PromptDialog open={modal?.kind === "new-folder"} onOpenChange={(o) => !o && setModal(null)} title="New folder" placeholder="Research, Clients, Ideas…" confirmLabel="Create" onSubmit={async (v) => { await api("/api/folders", { method: "POST", json: { name: v } }); await refreshFolders(); }} />
240 + <ConfirmDialog open={modal?.kind === "delete"} onOpenChange={(o) => !o && setModal(null)} title="Delete conversation?" description={modal?.kind === "delete" ? `“${modal.c.title}” and its messages will be permanently deleted.` : undefined} confirmLabel="Delete" destructive onConfirm={async () => { if (modal?.kind === "delete") await onDelete(modal.c); }} />
241 + <ConfirmDialog open={modal?.kind === "delete-folder"} onOpenChange={(o) => !o && setModal(null)} title="Delete folder?" description={modal?.kind === "delete-folder" ? `“${modal.name}” will be removed. Conversations inside are kept.` : undefined} confirmLabel="Delete folder" destructive onConfirm={async () => { if (modal?.kind !== "delete-folder") return; await api(`/api/folders?id=${modal.id}`, { method: "DELETE" }); await Promise.all([refreshFolders(), mutate()]); }} />
211 242 </div>
212 243 );
213 244 }
@@ -217,14 +248,14 @@ function Group({ title, count, children, icon, defaultOpen = true, onDeleteFolde
217 248 return (
218 249 <div className="pt-2">
219 250 <div className="group flex items-center justify-between px-2 pb-0.5">
220 <button onClick={() => setOpen((o) => !o)} className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle hover:text-fg">
251 + <button onClick={() => setOpen((o) => !o)} className="tap inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle hover:text-fg" aria-expanded={open}>
221 252 {open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
222 253 {icon}
223 254 {title}
224 255 <span className="ml-1 font-normal text-fg-subtle/70">{count}</span>
225 256 </button>
226 257 {onDeleteFolder ? (
227 <button onClick={onDeleteFolder} className="invisible rounded p-0.5 text-fg-subtle hover:text-danger group-hover:visible" aria-label="Delete folder">
258 + <button onClick={onDeleteFolder} className="hover-reveal tap rounded p-0.5 text-fg-subtle hover:text-danger" aria-label="Delete folder">
228 259 <Trash2 className="size-3" />
229 260 </button>
230 261 ) : null}
@@ -234,66 +265,82 @@ function Group({ title, count, children, icon, defaultOpen = true, onDeleteFolde
234 265 );
235 266 }
236 267
237 function ConversationRow({ c, active, folders, onRename, onPin, onArchive, onDelete, onDuplicate, onExport, onShare, onMove }: { c: PublicConversation; active: boolean; folders: { id: string; name: string }[]; onRename: () => void; onPin: () => void; onArchive: () => void; onDelete: () => void; onDuplicate: () => void; onExport: (f: "json" | "markdown") => void; onShare: () => void; onMove: (folderId: string | null) => void }) {
268 +function ConversationRow({ c, active, mobile, folders, onLongPress, onRename, onPin, onArchive, onDelete, onDuplicate, onExport, onShare, onMove }: { c: PublicConversation; active: boolean; mobile: boolean; folders: { id: string; name: string }[]; onLongPress: () => void; onRename: () => void; onPin: () => void; onArchive: () => void; onDelete: () => void; onDuplicate: () => void; onExport: (f: "json" | "markdown") => void; onShare: () => void; onMove: (folderId: string | null) => void }) {
269 + const router = useRouter();
270 + const press = useLongPress({ onLongPress, onClick: () => router.push(`/app/chat/${c.id}`), disabled: !mobile });
238 271 return (
239 272 <div className={cn("group relative flex items-center rounded-md", active ? "bg-bg-muted" : "hover:bg-bg-muted/70")}>
240 <Link href={`/app/chat/${c.id}`} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5">
241 <ProviderIcon provider={c.provider} size={13} className="opacity-80" />
242 <span className={cn("min-w-0 flex-1 truncate text-[13px]", active ? "text-fg" : "text-fg-muted group-hover:text-fg")}>{c.title}</span>
243 {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null}
244 </Link>
245 <DropdownMenu>
246 <DropdownMenuTrigger asChild>
247 <button className={cn("mr-1 rounded p-1 text-fg-subtle hover:bg-border hover:text-fg", active ? "" : "invisible group-hover:visible data-[state=open]:visible")} aria-label="Conversation actions">
248 <MoreHorizontal className="size-3.5" />
249 </button>
250 </DropdownMenuTrigger>
251 <DropdownMenuContent align="start" className="w-52">
252 <DropdownMenuItem onSelect={onRename}>
253 <Pencil /> Rename
254 </DropdownMenuItem>
255 <DropdownMenuItem onSelect={onPin}>
256 {c.pinned ? <PinOff /> : <Pin />} {c.pinned ? "Unpin" : "Pin"}
257 </DropdownMenuItem>
258 <DropdownMenuSub>
259 <DropdownMenuSubTrigger>
260 <FolderIcon /> Move to folder
261 </DropdownMenuSubTrigger>
262 <DropdownMenuSubContent>
263 <DropdownMenuLabel>Folders</DropdownMenuLabel>
264 <DropdownMenuItem onSelect={() => onMove(null)}>No folder</DropdownMenuItem>
265 {folders.map((f) => (
266 <DropdownMenuItem key={f.id} onSelect={() => onMove(f.id)}>
267 {f.name}
268 </DropdownMenuItem>
269 ))}
270 {folders.length === 0 ? <p className="px-2 py-1 text-xs text-fg-subtle">Create a folder from the sidebar footer.</p> : null}
271 </DropdownMenuSubContent>
272 </DropdownMenuSub>
273 <DropdownMenuItem onSelect={onDuplicate}>
274 <Copy /> Duplicate
275 </DropdownMenuItem>
276 <DropdownMenuItem onSelect={onShare}>
277 <Share2 /> Share link
278 </DropdownMenuItem>
279 <DropdownMenuSub>
280 <DropdownMenuSubTrigger>
281 <Download /> Export
282 </DropdownMenuSubTrigger>
283 <DropdownMenuSubContent>
284 <DropdownMenuItem onSelect={() => onExport("markdown")}>Markdown (.md)</DropdownMenuItem>
285 <DropdownMenuItem onSelect={() => onExport("json")}>JSON</DropdownMenuItem>
286 </DropdownMenuSubContent>
287 </DropdownMenuSub>
288 <DropdownMenuItem onSelect={onArchive}>
289 <Archive /> {c.archived ? "Unarchive" : "Archive"}
290 </DropdownMenuItem>
291 <DropdownMenuSeparator />
292 <DropdownMenuItem destructive onSelect={onDelete}>
293 <Trash2 /> Delete
294 </DropdownMenuItem>
295 </DropdownMenuContent>
296 </DropdownMenu>
273 + {mobile ? (
274 + <button type="button" {...press} className="flex min-h-[44px] min-w-0 flex-1 select-none items-center gap-2.5 px-2 text-left" style={{ WebkitTouchCallout: "none" }}>
275 + <ProviderIcon provider={c.provider} size={14} className="opacity-80" />
276 + <span className={cn("min-w-0 flex-1 truncate text-[15px]", active ? "text-fg" : "text-fg-muted")}>{c.title}</span>
277 + {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null}
278 + </button>
279 + ) : (
280 + <Link href={`/app/chat/${c.id}`} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5">
281 + <ProviderIcon provider={c.provider} size={13} className="opacity-80" />
282 + <span className={cn("min-w-0 flex-1 truncate text-[13px]", active ? "text-fg" : "text-fg-muted group-hover:text-fg")}>{c.title}</span>
283 + {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null}
284 + </Link>
285 + )}
286 + {mobile ? (
287 + <button onClick={onLongPress} className="tap mr-1 rounded p-2 text-fg-subtle" aria-label="Conversation actions">
288 + <MoreHorizontal className="size-4" />
289 + </button>
290 + ) : (
291 + <DropdownMenu>
292 + <DropdownMenuTrigger asChild>
293 + <button className={cn("mr-1 rounded p-1 text-fg-subtle hover:bg-border hover:text-fg", active ? "" : "invisible group-hover:visible data-[state=open]:visible")} aria-label="Conversation actions">
294 + <MoreHorizontal className="size-3.5" />
295 + </button>
296 + </DropdownMenuTrigger>
297 + <DropdownMenuContent align="start" className="w-52">
298 + <DropdownMenuItem onSelect={onRename}>
299 + <Pencil /> Rename
300 + </DropdownMenuItem>
301 + <DropdownMenuItem onSelect={onPin}>
302 + {c.pinned ? <PinOff /> : <Pin />} {c.pinned ? "Unpin" : "Pin"}
303 + </DropdownMenuItem>
304 + <DropdownMenuSub>
305 + <DropdownMenuSubTrigger>
306 + <FolderIcon /> Move to folder
307 + </DropdownMenuSubTrigger>
308 + <DropdownMenuSubContent>
309 + <DropdownMenuLabel>Folders</DropdownMenuLabel>
310 + <DropdownMenuItem onSelect={() => onMove(null)}>No folder</DropdownMenuItem>
311 + {folders.map((f) => (
312 + <DropdownMenuItem key={f.id} onSelect={() => onMove(f.id)}>
313 + {f.name}
314 + </DropdownMenuItem>
315 + ))}
316 + {folders.length === 0 ? <p className="px-2 py-1 text-xs text-fg-subtle">Create a folder from the sidebar footer.</p> : null}
317 + </DropdownMenuSubContent>
318 + </DropdownMenuSub>
319 + <DropdownMenuItem onSelect={onDuplicate}>
320 + <Copy /> Duplicate
321 + </DropdownMenuItem>
322 + <DropdownMenuItem onSelect={onShare}>
323 + <Share2 /> Share link
324 + </DropdownMenuItem>
325 + <DropdownMenuSub>
326 + <DropdownMenuSubTrigger>
327 + <Download /> Export
328 + </DropdownMenuSubTrigger>
329 + <DropdownMenuSubContent>
330 + <DropdownMenuItem onSelect={() => onExport("markdown")}>Markdown (.md)</DropdownMenuItem>
331 + <DropdownMenuItem onSelect={() => onExport("json")}>JSON</DropdownMenuItem>
332 + </DropdownMenuSubContent>
333 + </DropdownMenuSub>
334 + <DropdownMenuItem onSelect={onArchive}>
335 + <Archive /> {c.archived ? "Unarchive" : "Archive"}
336 + </DropdownMenuItem>
337 + <DropdownMenuSeparator />
338 + <DropdownMenuItem destructive onSelect={onDelete}>
339 + <Trash2 /> Delete
340 + </DropdownMenuItem>
341 + </DropdownMenuContent>
342 + </DropdownMenu>
343 + )}
297 344 </div>
298 345 );
299 346 }
modified src/components/app/store.tsx +38 −2
@@ -14,6 +14,9 @@ export interface AppUser {
14 14 createdAt: string;
15 15 }
16 16
17 +/** Smart-router selection sentinel — stored in `selectedModelKey` when the user picks AUTO. */
18 +export const AUTO_MODEL_KEY = "auto";
19 +
17 20 interface AppState {
18 21 user: AppUser;
19 22 preferences: UserPreferences;
@@ -22,21 +25,30 @@ interface AppState {
22 25 modelsByKey: Map<string, PolyModel>;
23 26 favorites: Set<string>;
24 27 recents: string[];
28 + /** User-defined labels per model key ("My research model"). */
29 + labels: Record<string, string>;
25 30 connectedProviders: Set<ProviderId>;
26 31 connections: PublicConnection[];
27 32 folders: Folder[];
28 33 loadingModels: boolean;
29 34 toggleFavorite: (key: string) => Promise<void>;
35 + setLabel: (key: string, label: string | null) => Promise<void>;
30 36 refreshModels: () => Promise<void>;
31 37 refreshConnections: () => Promise<void>;
32 38 refreshFolders: () => Promise<void>;
33 /** Currently selected model key for new chats (persisted in localStorage). */
39 + /** Currently selected model key for new chats (persisted in localStorage). `AUTO_MODEL_KEY` = smart router. */
34 40 selectedModelKey: string | null;
35 41 setSelectedModelKey: (key: string | null) => void;
42 + /** Active project (workspace) for new chats; null = no project. Persisted. */
43 + activeProjectId: string | null;
44 + setActiveProjectId: (id: string | null) => void;
36 45 sidebarOpen: boolean;
37 46 setSidebarOpen: (v: boolean) => void;
38 47 paletteOpen: boolean;
39 48 setPaletteOpen: (v: boolean) => void;
49 + /** Open the global conversation search (⌘K search mode / mobile sheet). */
50 + searchOpen: boolean;
51 + setSearchOpen: (v: boolean) => void;
40 52 }
41 53
42 54 const Ctx = React.createContext<AppState | null>(null);
@@ -48,6 +60,7 @@ export function useApp(): AppState {
48 60 }
49 61
50 62 const LS_MODEL = "polyllm:selected-model";
63 +const LS_PROJECT = "polyllm:active-project";
51 64
52 65 export function AppProvider({ user, preferences: initialPrefs, children }: { user: AppUser; preferences: UserPreferences; children: React.ReactNode }) {
53 66 const [preferences, setPreferences] = React.useState(initialPrefs);
@@ -55,23 +68,28 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
55 68 const connections = useApi<{ connections: PublicConnection[] }>("/api/providers");
56 69 const foldersQ = useApi<{ folders: Folder[] }>("/api/folders");
57 70 const [selectedModelKey, setSelected] = React.useState<string | null>(null);
71 + const [activeProjectId, setProject] = React.useState<string | null>(null);
58 72 const [sidebarOpen, setSidebarOpen] = React.useState(false);
59 73 const [paletteOpen, setPaletteOpen] = React.useState(false);
74 + const [searchOpen, setSearchOpen] = React.useState(false);
60 75
61 76 React.useEffect(() => {
62 77 const stored = typeof window !== "undefined" ? window.localStorage.getItem(LS_MODEL) : null;
63 78 // eslint-disable-next-line react-hooks/set-state-in-effect
64 79 setSelected(stored ?? initialPrefs.defaultModelKey ?? null);
80 + setProject(window.localStorage.getItem(LS_PROJECT) || null);
65 81 }, [initialPrefs.defaultModelKey]);
66 82
67 83 const modelList = React.useMemo(() => models.data?.models ?? [], [models.data]);
68 84 const modelsByKey = React.useMemo(() => new Map(modelList.map((m) => [m.key, m])), [modelList]);
69 85 const favorites = React.useMemo(() => new Set(models.data?.favorites ?? []), [models.data]);
86 + const labels = React.useMemo(() => models.data?.labels ?? {}, [models.data]);
70 87 const connectedProviders = React.useMemo(() => new Set(models.data?.connectedProviders ?? []), [models.data]);
71 88
72 // Fall back to the first connected model when the stored selection is unusable.
89 + // Fall back to the first connected model when the stored selection is unusable (AUTO is always valid).
73 90 React.useEffect(() => {
74 91 if (!models.data) return;
92 + if (selectedModelKey === AUTO_MODEL_KEY) return;
75 93 if (selectedModelKey && modelsByKey.has(selectedModelKey) && connectedProviders.has(modelsByKey.get(selectedModelKey)!.provider)) return;
76 94 const first = modelList.find((m) => connectedProviders.has(m.provider) && m.status !== "deprecated");
77 95 if (first && first.key !== selectedModelKey) {
@@ -86,6 +104,12 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
86 104 else window.localStorage.removeItem(LS_MODEL);
87 105 }, []);
88 106
107 + const setActiveProjectId = React.useCallback((id: string | null) => {
108 + setProject(id);
109 + if (id) window.localStorage.setItem(LS_PROJECT, id);
110 + else window.localStorage.removeItem(LS_PROJECT);
111 + }, []);
112 +
89 113 const value: AppState = {
90 114 user,
91 115 preferences,
@@ -97,6 +121,7 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
97 121 modelsByKey,
98 122 favorites,
99 123 recents: models.data?.recents ?? [],
124 + labels,
100 125 connectedProviders,
101 126 connections: connections.data?.connections ?? [],
102 127 folders: foldersQ.data?.folders ?? [],
@@ -105,6 +130,10 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
105 130 await api("/api/models", { method: "POST", json: { action: "toggle-favorite", modelKey: key } });
106 131 await models.mutate();
107 132 },
133 + setLabel: async (key, label) => {
134 + await api("/api/models", { method: "POST", json: { action: "set-label", modelKey: key, label } });
135 + await models.mutate();
136 + },
108 137 refreshModels: async () => {
109 138 await models.mutate();
110 139 },
@@ -116,10 +145,14 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
116 145 },
117 146 selectedModelKey,
118 147 setSelectedModelKey,
148 + activeProjectId,
149 + setActiveProjectId,
119 150 sidebarOpen,
120 151 setSidebarOpen,
121 152 paletteOpen,
122 153 setPaletteOpen,
154 + searchOpen,
155 + setSearchOpen,
123 156 };
124 157 return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
125 158 }
@@ -127,3 +160,6 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
127 160 export function invalidateConversations() {
128 161 return revalidate("/api/conversations");
129 162 }
163 +export function invalidateProjects() {
164 + return revalidate("/api/projects");
165 +}
modified src/components/brand/logo.tsx +44 −15
@@ -2,35 +2,64 @@
2 2 import { useId } from "react";
3 3 import { cn } from "@/lib/utils";
4 4
5 /** PolyLLM mark: four overlapping nodes converging into one — "one interface, every model". */
6 export function LogoMark({ className, size = 28 }: { className?: string; size?: number }) {
5 +/**
6 + * PolyLLM mark — "the Prism P".
7 + * A bold P (Poly) whose bowl emits three beams of decreasing intensity: one interface, every model.
8 + * Keep in sync with `public/icon.svg` and `scripts/brand-assets.sh` (favicon / PWA / OG renders).
9 + */
10 +export function LogoMark({ className, size = 28, mono = false }: { className?: string; size?: number; mono?: boolean }) {
7 11 // Unique gradient id per instance: a duplicate id inside a `display:none` sidebar would otherwise
8 12 // win the lookup and the mark would paint black on mobile.
9 const gid = `plm-g-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
13 + const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
14 + const gid = `plm-g-${uid}`;
15 + const hid = `plm-h-${uid}`;
10 16 return (
11 17 <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn("shrink-0", className)} aria-hidden>
12 18 <defs>
13 <linearGradient id={gid} x1="4" y1="4" x2="28" y2="28" gradientUnits="userSpaceOnUse">
14 <stop stopColor="#8b8dff" />
15 <stop offset="0.55" stopColor="#b58cff" />
16 <stop offset="1" stopColor="#ff8fab" />
19 + <linearGradient id={gid} x1="3" y1="3" x2="29" y2="29" gradientUnits="userSpaceOnUse">
20 + <stop stopColor="#6366f1" />
21 + <stop offset="0.52" stopColor="#8b5cf6" />
22 + <stop offset="1" stopColor="#f472b6" />
17 23 </linearGradient>
24 + <radialGradient id={hid} cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9 6) rotate(48) scale(26 22)">
25 + <stop stopColor="#fff" stopOpacity="0.32" />
26 + <stop offset="1" stopColor="#fff" stopOpacity="0" />
27 + </radialGradient>
18 28 </defs>
19 <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} />
20 <g stroke="#0b0c10" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
21 <path d="M9 10.5h4.5M9 16h6M9 21.5h4.5" />
22 <path d="M23 10.5h-4.5M23 16h-6M23 21.5h-4.5" />
23 <circle cx="16" cy="16" r="2.4" fill="#0b0c10" stroke="none" />
29 + {mono ? <rect x="1" y="1" width="30" height="30" rx="9" fill="currentColor" /> : (
30 + <>
31 + <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} />
32 + <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${hid})`} />
33 + </>
34 + )}
35 + <g fill={mono ? "var(--bg, #fff)" : "#fff"}>
36 + {/* P stem */}
37 + <rect x="7.5" y="7" width="5" height="18" rx="2.5" />
38 + {/* P bowl (ring) */}
39 + <path fillRule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z" />
40 + {/* Beams: one → many */}
41 + <rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95" />
42 + <rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62" />
43 + <rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34" />
24 44 </g>
25 45 </svg>
26 46 );
27 47 }
28 48
29 export function Logo({ className, size = 24, wordmark = true }: { className?: string; size?: number; wordmark?: boolean }) {
49 +export function Logo({ className, size = 24, wordmark = true, mono }: { className?: string; size?: number; wordmark?: boolean; mono?: boolean }) {
30 50 return (
31 51 <span className={cn("inline-flex items-center gap-2", className)}>
32 <LogoMark size={size} />
33 {wordmark ? <span className="text-[17px] font-semibold tracking-tight leading-none">PolyLLM</span> : null}
52 + <LogoMark size={size} mono={mono} />
53 + {wordmark ? <Wordmark className="text-[17px]" /> : null}
54 + </span>
55 + );
56 +}
57 +
58 +/** Text wordmark — "Poly" solid, "LLM" slightly lighter for rhythm. */
59 +export function Wordmark({ className }: { className?: string }) {
60 + return (
61 + <span className={cn("font-semibold tracking-[-0.02em] leading-none", className)}>
62 + Poly<span className="font-medium text-fg-muted">LLM</span>
34 63 </span>
35 64 );
36 65 }
added src/components/common/prompt-dialog.tsx +86 −0
@@ -0,0 +1,86 @@
1 +"use client";
2 +import * as React from "react";
3 +import { ResponsiveDialog } from "@/components/ui/sheet";
4 +import { Button } from "@/components/ui/button";
5 +import { Input, Textarea } from "@/components/ui/input";
6 +
7 +export interface PromptDialogProps {
8 + open: boolean;
9 + onOpenChange: (open: boolean) => void;
10 + title: React.ReactNode;
11 + description?: React.ReactNode;
12 + label?: string;
13 + placeholder?: string;
14 + defaultValue?: string;
15 + confirmLabel?: string;
16 + multiline?: boolean;
17 + maxLength?: number;
18 + onSubmit: (value: string) => Promise<void> | void;
19 +}
20 +
21 +/**
22 + * Replacement for `window.prompt()`: bottom sheet on phones, small dialog on desktop.
23 + * Resolves with the trimmed value; empty submissions are ignored.
24 + */
25 +export function PromptDialog({ open, onOpenChange, title, description, label, placeholder, defaultValue = "", confirmLabel = "Save", multiline, maxLength = 200, onSubmit }: PromptDialogProps) {
26 + const [value, setValue] = React.useState(defaultValue);
27 + const [busy, setBusy] = React.useState(false);
28 + const ref = React.useRef<HTMLInputElement & HTMLTextAreaElement>(null);
29 + React.useEffect(() => {
30 + if (open) {
31 + // eslint-disable-next-line react-hooks/set-state-in-effect
32 + setValue(defaultValue);
33 + setTimeout(() => {
34 + ref.current?.focus();
35 + ref.current?.select();
36 + }, 60);
37 + }
38 + }, [open, defaultValue]);
39 +
40 + const submit = async () => {
41 + const v = value.trim();
42 + if (!v || busy) return;
43 + setBusy(true);
44 + try {
45 + await onSubmit(v);
46 + onOpenChange(false);
47 + } finally {
48 + setBusy(false);
49 + }
50 + };
51 +
52 + return (
53 + <ResponsiveDialog
54 + open={open}
55 + onOpenChange={onOpenChange}
56 + title={title}
57 + description={description}
58 + size="sm"
59 + footer={
60 + <div className="flex gap-2 sm:justify-end">
61 + <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => onOpenChange(false)} disabled={busy}>
62 + Cancel
63 + </Button>
64 + <Button className="flex-1 sm:flex-none" loading={busy} disabled={!value.trim()} onClick={submit}>
65 + {confirmLabel}
66 + </Button>
67 + </div>
68 + }
69 + >
70 + <form
71 + className="space-y-1.5 pt-1"
72 + onSubmit={(e) => {
73 + e.preventDefault();
74 + void submit();
75 + }}
76 + >
77 + {label ? <label className="text-[13px] font-medium text-fg-muted">{label}</label> : null}
78 + {multiline ? (
79 + <Textarea ref={ref} value={value} onChange={(e) => setValue(e.target.value)} placeholder={placeholder} maxLength={maxLength} className="min-h-[120px]" />
80 + ) : (
81 + <Input ref={ref} value={value} onChange={(e) => setValue(e.target.value)} placeholder={placeholder} maxLength={maxLength} className="h-11 sm:h-9" />
82 + )}
83 + </form>
84 + </ResponsiveDialog>
85 + );
86 +}
added src/components/library/file-library-picker.tsx +110 −0
@@ -0,0 +1,110 @@
1 +"use client";
2 +import * as React from "react";
3 +import { FileText, Image as ImageIcon, Library } from "lucide-react";
4 +import { ResponsiveDialog } from "@/components/ui/sheet";
5 +import { Button } from "@/components/ui/button";
6 +import { EmptyState } from "@/components/ui/misc";
7 +import { useApi, api } from "@/lib/client/api";
8 +import { useApp } from "@/components/app/store";
9 +import type { ProjectFile } from "@/lib/client/types";
10 +import { formatTokens, formatRelative, cn } from "@/lib/utils";
11 +
12 +export interface PickedAttachment {
13 + id: string;
14 + kind: string;
15 + name: string;
16 + mimeType: string;
17 + sizeBytes: number;
18 + width?: number | null;
19 + height?: number | null;
20 +}
21 +
22 +type PublicProjectFile = Omit<ProjectFile, "dataBase64" | "createdAt"> & { createdAt: string };
23 +
24 +/**
25 + * 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.)
29 + */
30 +export function FileLibraryPicker({ open, onOpenChange, onPick, projectId }: { open: boolean; onOpenChange: (o: boolean) => void; onPick: (files: PickedAttachment[]) => void; projectId?: string | null }) {
31 + const { activeProjectId } = useApp();
32 + const pid = projectId ?? activeProjectId;
33 + const { data, isLoading } = useApi<{ files: PublicProjectFile[] }>(open ? `/api/library/files${pid ? `?projectId=${pid}` : ""}` : null);
34 + const [selected, setSelected] = React.useState<Set<string>>(new Set());
35 + const [busy, setBusy] = React.useState(false);
36 + React.useEffect(() => {
37 + // eslint-disable-next-line react-hooks/set-state-in-effect
38 + if (!open) setSelected(new Set());
39 + }, [open]);
40 +
41 + const files = data?.files ?? [];
42 + const toggle = (id: string) =>
43 + setSelected((s) => {
44 + const n = new Set(s);
45 + if (n.has(id)) n.delete(id);
46 + else n.add(id);
47 + return n;
48 + });
49 +
50 + const attach = async () => {
51 + setBusy(true);
52 + try {
53 + const res = await api<{ attachments: PickedAttachment[] }>("/api/library/files/attach", { method: "POST", json: { fileIds: [...selected] } });
54 + onPick(res.attachments);
55 + onOpenChange(false);
56 + } finally {
57 + setBusy(false);
58 + }
59 + };
60 +
61 + return (
62 + <ResponsiveDialog
63 + open={open}
64 + onOpenChange={onOpenChange}
65 + title="Attach from library"
66 + description={pid ? "Files saved to this project and your global library." : "Files saved to your library."}
67 + size="md"
68 + footer={
69 + <div className="flex items-center gap-2">
70 + <span className="text-[13px] text-fg-muted">{selected.size} selected</span>
71 + <div className="flex-1" />
72 + <Button variant="ghost" onClick={() => onOpenChange(false)}>
73 + Cancel
74 + </Button>
75 + <Button disabled={!selected.size} loading={busy} onClick={attach}>
76 + Attach
77 + </Button>
78 + </div>
79 + }
80 + >
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)}
97 + </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 + )}
108 + </ResponsiveDialog>
109 + );
110 +}
added src/components/projects/sidebar-section.tsx +46 −0
@@ -0,0 +1,46 @@
1 +"use client";
2 +import * as React from "react";
3 +import Link from "next/link";
4 +import { FolderKanban, Plus } from "lucide-react";
5 +import { useApp } from "@/components/app/store";
6 +import { useApi } from "@/lib/client/api";
7 +import type { Project } from "@/lib/client/types";
8 +import { cn } from "@/lib/utils";
9 +
10 +/**
11 + * Projects list rendered inside the sidebar / mobile drawer.
12 + * (Owned by the Projects workstream — this baseline lists projects and links to /app/projects.)
13 + */
14 +export function ProjectsSidebarSection({ onNavigate }: { onNavigate?: () => void }) {
15 + const { activeProjectId } = useApp();
16 + const { data } = useApi<{ projects: Project[] }>("/api/projects");
17 + const list = React.useMemo(() => (data?.projects ?? []).filter((p) => !p.archived).slice(0, 8), [data]);
18 + return (
19 + <div className="pt-2">
20 + <div className="flex items-center justify-between px-2 pb-0.5">
21 + <Link href="/app/projects" onClick={onNavigate} className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle hover:text-fg">
22 + <FolderKanban className="size-3" /> Projects
23 + </Link>
24 + <Link href="/app/projects?new=1" onClick={onNavigate} className="tap rounded p-0.5 text-fg-subtle hover:text-fg" aria-label="New project">
25 + <Plus className="size-3.5" />
26 + </Link>
27 + </div>
28 + {list.length === 0 ? (
29 + <Link href="/app/projects?new=1" onClick={onNavigate} className="mx-2 mt-1 block rounded-md border border-dashed border-border px-2.5 py-2 text-[12px] text-fg-subtle hover:border-border-strong hover:text-fg">
30 + Group chats, files and instructions into a project.
31 + </Link>
32 + ) : (
33 + <div className="space-y-px">
34 + {list.map((p) => (
35 + <Link key={p.id} href={`/app/projects/${p.id}`} onClick={onNavigate} className={cn("flex min-h-[36px] items-center gap-2 rounded-md px-2 text-[13px] md:min-h-[30px]", p.id === activeProjectId ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}>
36 + <span className="flex size-4 items-center justify-center text-[12px]" aria-hidden>
37 + {p.icon ?? "◆"}
38 + </span>
39 + <span className="truncate">{p.name}</span>
40 + </Link>
41 + ))}
42 + </div>
43 + )}
44 + </div>
45 + );
46 +}
modified src/components/ui/misc.tsx +5 −0
@@ -101,3 +101,8 @@ export function Spinner({ className }: { className?: string }) {
101 101 </svg>
102 102 );
103 103 }
104 +
105 +/** Marks a control whose feature is not implemented yet. Never ship a silent placeholder button. */
106 +export function ComingSoon({ className }: { className?: string }) {
107 + return <span className={cn("inline-flex items-center rounded-full border border-dashed border-border-strong px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-subtle", className)}>Coming soon</span>;
108 +}
added src/components/ui/segmented.tsx +90 −0
@@ -0,0 +1,90 @@
1 +"use client";
2 +import * as React from "react";
3 +import { cn } from "@/lib/utils";
4 +
5 +export interface SegmentedOption<T extends string> {
6 + value: T;
7 + label: React.ReactNode;
8 + icon?: React.ReactNode;
9 + disabled?: boolean;
10 + count?: number;
11 +}
12 +
13 +/**
14 + * Segmented control (iOS-like). Keyboard: ←/→ move, Enter/Space select. Scrolls horizontally when it overflows.
15 + */
16 +export function Segmented<T extends string>({ value, onChange, options, size = "md", className, ariaLabel, fill }: { value: T; onChange: (v: T) => void; options: SegmentedOption<T>[]; size?: "sm" | "md" | "lg"; className?: string; ariaLabel?: string; fill?: boolean }) {
17 + const ref = React.useRef<HTMLDivElement>(null);
18 + const idx = options.findIndex((o) => o.value === value);
19 + const onKey = (e: React.KeyboardEvent) => {
20 + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
21 + e.preventDefault();
22 + const dir = e.key === "ArrowLeft" ? -1 : 1;
23 + let i = idx;
24 + for (let n = 0; n < options.length; n++) {
25 + i = (i + dir + options.length) % options.length;
26 + if (!options[i].disabled) break;
27 + }
28 + onChange(options[i].value);
29 + (ref.current?.children[i] as HTMLElement | undefined)?.focus();
30 + };
31 + const h = size === "sm" ? "h-8" : size === "lg" ? "h-11" : "h-9";
32 + const item = size === "sm" ? "h-6 px-2.5 text-[12.5px]" : size === "lg" ? "h-9 px-4 text-[15px]" : "h-7 px-3 text-[13px]";
33 + return (
34 + <div ref={ref} role="tablist" aria-label={ariaLabel} onKeyDown={onKey} className={cn("inline-flex max-w-full items-center gap-0.5 overflow-x-auto rounded-lg bg-bg-muted p-1 scrollbar-none", h, fill && "flex w-full", className)}>
35 + {options.map((o) => {
36 + const active = o.value === value;
37 + return (
38 + <button
39 + key={o.value}
40 + role="tab"
41 + type="button"
42 + aria-selected={active}
43 + tabIndex={active ? 0 : -1}
44 + disabled={o.disabled}
45 + onClick={() => onChange(o.value)}
46 + className={cn(
47 + "inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-[background-color,color,box-shadow] duration-150 disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
48 + item,
49 + fill && "flex-1",
50 + active ? "bg-bg-elevated text-fg shadow-xs" : "text-fg-muted hover:text-fg",
51 + )}
52 + >
53 + {o.icon}
54 + {o.label}
55 + {o.count !== undefined ? <span className={cn("tabular-nums text-[11px]", active ? "text-fg-subtle" : "text-fg-subtle/70")}>{o.count}</span> : null}
56 + </button>
57 + );
58 + })}
59 + </div>
60 + );
61 +}
62 +
63 +/** Pill filter chips (multi or single select). Horizontal scroll on phones. */
64 +export function ChipRow<T extends string>({ value, onChange, options, className, multiple }: { value: T | T[] | null; onChange: (v: T) => void; options: { value: T; label: React.ReactNode; icon?: React.ReactNode; count?: number }[]; className?: string; multiple?: boolean }) {
65 + const isActive = (v: T) => (Array.isArray(value) ? value.includes(v) : value === v);
66 + return (
67 + <div className={cn("flex gap-1.5 overflow-x-auto scrollbar-none", className)} role={multiple ? "group" : "radiogroup"}>
68 + {options.map((o) => {
69 + const active = isActive(o.value);
70 + return (
71 + <button
72 + key={o.value}
73 + type="button"
74 + role={multiple ? "checkbox" : "radio"}
75 + aria-checked={active}
76 + onClick={() => onChange(o.value)}
77 + className={cn(
78 + "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition-colors [&_svg]:size-3.5",
79 + active ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg",
80 + )}
81 + >
82 + {o.icon}
83 + {o.label}
84 + {o.count !== undefined ? <span className={cn("tabular-nums text-[11px]", active ? "opacity-70" : "text-fg-subtle")}>{o.count}</span> : null}
85 + </button>
86 + );
87 + })}
88 + </div>
89 + );
90 +}
added src/components/ui/sheet.tsx +265 −0
@@ -0,0 +1,265 @@
1 +"use client";
2 +import * as React from "react";
3 +import * as DialogPrimitive from "@radix-ui/react-dialog";
4 +import { X } from "lucide-react";
5 +import { cn } from "@/lib/utils";
6 +import { useIsMobile, usePrefersReducedMotion } from "@/lib/client/hooks";
7 +
8 +/**
9 + * BottomSheet — native-feeling mobile sheet built on Radix Dialog (focus trap, a11y, Esc).
10 + *
11 + * - drag handle + swipe-to-dismiss (pointer events; direction-locked so inner scrolling still works)
12 + * - snap points: `snap="content" | "half" | "full"` (max height); `expandable` lets the user drag up to full
13 + * - background blur, safe-area padding, `100dvh`-based sizing, keyboard-aware (`--kb`)
14 + *
15 + * Use `ResponsiveDialog` when the same content must be a centered dialog on desktop.
16 + */
17 +export type SheetSnap = "content" | "half" | "full";
18 +
19 +export interface BottomSheetProps {
20 + open: boolean;
21 + onOpenChange: (open: boolean) => void;
22 + title?: React.ReactNode;
23 + description?: React.ReactNode;
24 + children: React.ReactNode;
25 + /** Sticky footer (e.g. primary action). Rendered above the safe area. */
26 + footer?: React.ReactNode;
27 + snap?: SheetSnap;
28 + /** Allow dragging up from `content`/`half` to `full`. */
29 + expandable?: boolean;
30 + /** Hide the visual title but keep it for screen readers. */
31 + hideTitle?: boolean;
32 + showClose?: boolean;
33 + className?: string;
34 + bodyClassName?: string;
35 + /** Prevent closing by tapping the overlay / swiping. */
36 + modal?: boolean;
37 + /** Remove body padding (lists that manage their own). */
38 + flush?: boolean;
39 + /** Let a fixed header (e.g. search input) sit above the scrolling body. */
40 + header?: React.ReactNode;
41 +}
42 +
43 +const SNAP_MAX: Record<SheetSnap, string> = {
44 + content: "max-h-[min(88dvh,calc(100dvh-var(--kb)-16px))]",
45 + half: "h-[min(56dvh,calc(100dvh-var(--kb)-16px))]",
46 + full: "h-[calc(100dvh-var(--kb)-var(--sat)-8px)]",
47 +};
48 +
49 +export function BottomSheet({ open, onOpenChange, title, description, children, footer, snap = "content", expandable = true, hideTitle, showClose = false, className, bodyClassName, modal = false, flush, header }: BottomSheetProps) {
50 + const reduced = usePrefersReducedMotion();
51 + const [current, setCurrent] = React.useState<SheetSnap>(snap);
52 + const [dragY, setDragY] = React.useState(0);
53 + const [dragging, setDragging] = React.useState(false);
54 + const bodyRef = React.useRef<HTMLDivElement>(null);
55 + const startY = React.useRef<number | null>(null);
56 + const startScrollTop = React.useRef(0);
57 + const axis = React.useRef<"y" | "none" | null>(null);
58 +
59 + React.useEffect(() => {
60 + if (open) {
61 + // eslint-disable-next-line react-hooks/set-state-in-effect
62 + setCurrent(snap);
63 + setDragY(0);
64 + }
65 + }, [open, snap]);
66 +
67 + const onPointerDown = (e: React.PointerEvent, fromHandle: boolean) => {
68 + if (e.pointerType === "mouse" && !fromHandle) return;
69 + startY.current = e.clientY;
70 + startScrollTop.current = bodyRef.current?.scrollTop ?? 0;
71 + axis.current = fromHandle ? "y" : null;
72 + };
73 + const onPointerMove = (e: React.PointerEvent) => {
74 + if (startY.current === null) return;
75 + const dy = e.clientY - startY.current;
76 + if (axis.current === null) {
77 + // Only take over when the body is scrolled to the top and the user pulls down.
78 + if (Math.abs(dy) < 8) return;
79 + axis.current = dy > 0 && startScrollTop.current <= 0 ? "y" : "none";
80 + }
81 + if (axis.current !== "y") return;
82 + if (dy > 0) {
83 + setDragging(true);
84 + setDragY(dy);
85 + } else if (expandable && current !== "full") {
86 + setDragging(true);
87 + setDragY(Math.max(dy, -80));
88 + }
89 + };
90 + const onPointerUp = () => {
91 + if (startY.current === null) return;
92 + const dy = dragY;
93 + startY.current = null;
94 + axis.current = null;
95 + setDragging(false);
96 + setDragY(0);
97 + if (dy > 110 && !modal) onOpenChange(false);
98 + else if (dy < -40 && expandable && current !== "full") setCurrent("full");
99 + else if (dy > 60 && current === "full" && snap !== "full") setCurrent(snap);
100 + };
101 +
102 + return (
103 + <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>
104 + <DialogPrimitive.Portal>
105 + <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[3px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
106 + <DialogPrimitive.Content
107 + onOpenAutoFocus={(e) => e.preventDefault()}
108 + className={cn(
109 + "fixed inset-x-0 bottom-0 z-50 flex flex-col bg-bg-elevated text-fg shadow-sheet focus:outline-none",
110 + "rounded-t-[22px] border-t border-border/70",
111 + SNAP_MAX[current],
112 + !dragging && !reduced && "data-[state=open]:animate-sheet-in data-[state=closed]:animate-sheet-out transition-[height,max-height] duration-300 ease-[var(--ease-out-soft)]",
113 + className,
114 + )}
115 + style={dragging ? { transform: `translateY(${Math.max(0, dragY)}px)` } : undefined}
116 + onPointerMove={onPointerMove}
117 + onPointerUp={onPointerUp}
118 + onPointerCancel={onPointerUp}
119 + >
120 + {/* Handle: always draggable */}
121 + <div className="shrink-0 cursor-grab touch-none select-none active:cursor-grabbing" onPointerDown={(e) => onPointerDown(e, true)} aria-hidden>
122 + <div className="sheet-handle" />
123 + </div>
124 + <div className={cn("shrink-0 px-5", title && !hideTitle ? "pb-2 pt-1" : "")}>
125 + <DialogPrimitive.Title className={cn("text-[17px] font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Sheet"}</DialogPrimitive.Title>
126 + {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-[13px] text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}
127 + {showClose ? (
128 + <DialogPrimitive.Close className="tap absolute right-3 top-3 rounded-full bg-bg-muted p-1.5 text-fg-muted hover:text-fg" aria-label="Close">
129 + <X className="size-4" />
130 + </DialogPrimitive.Close>
131 + ) : null}
132 + </div>
133 + {header ? <div className="shrink-0">{header}</div> : null}
134 + <div ref={bodyRef} onPointerDown={(e) => onPointerDown(e, false)} className={cn("min-h-0 flex-1 overflow-y-auto contain-scroll scrollbar-thin", flush ? "" : "px-4 pb-4", !footer && "pb-[max(16px,var(--sab))]", bodyClassName)}>
135 + {children}
136 + </div>
137 + {footer ? <div className="shrink-0 border-t border-border bg-bg-elevated px-4 pt-3 pb-[max(12px,var(--sab))]">{footer}</div> : null}
138 + </DialogPrimitive.Content>
139 + </DialogPrimitive.Portal>
140 + </DialogPrimitive.Root>
141 + );
142 +}
143 +
144 +/* ------------------------------------------------------------------------------------------------ */
145 +
146 +export interface ResponsiveDialogProps extends Omit<BottomSheetProps, "snap" | "expandable"> {
147 + /** Desktop width. */
148 + size?: "sm" | "md" | "lg" | "xl" | "full";
149 + /** Mobile snap point (default content). */
150 + snap?: SheetSnap;
151 + expandable?: boolean;
152 + /** Desktop variant: centered dialog (default) or right side panel. */
153 + desktop?: "dialog" | "panel";
154 + /** Desktop: remove the default body padding. */
155 + desktopFlush?: boolean;
156 +}
157 +
158 +const SIZES = { sm: "sm:max-w-sm", md: "sm:max-w-lg", lg: "sm:max-w-2xl", xl: "sm:max-w-4xl", full: "sm:max-w-[min(96vw,1200px)]" };
159 +
160 +/**
161 + * Bottom sheet on phones, centered dialog (or side panel) on ≥ md. One API, two native-feeling shapes.
162 + */
163 +export function ResponsiveDialog({ size = "md", desktop = "dialog", desktopFlush, ...props }: ResponsiveDialogProps) {
164 + const isMobile = useIsMobile();
165 + if (isMobile) return <BottomSheet {...props} />;
166 + const { open, onOpenChange, title, description, children, footer, hideTitle, showClose = true, className, bodyClassName, modal, flush, header } = props;
167 + const isPanel = desktop === "panel";
168 + return (
169 + <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>
170 + <DialogPrimitive.Portal>
171 + <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
172 + <DialogPrimitive.Content
173 + className={cn(
174 + "fixed z-50 flex flex-col border border-border bg-bg-elevated text-fg shadow-lg focus:outline-none",
175 + isPanel
176 + ? "inset-y-2 right-2 w-[min(440px,calc(100vw-16px))] rounded-2xl data-[state=open]:animate-fade-up data-[state=closed]:animate-fade-out"
177 + : cn("left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 rounded-2xl max-h-[min(86vh,900px)] data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out", SIZES[size]),
178 + className,
179 + )}
180 + >
181 + <div className={cn("shrink-0", title && !hideTitle ? "px-6 pt-5 pb-3" : "")}>
182 + <DialogPrimitive.Title className={cn("text-base font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Dialog"}</DialogPrimitive.Title>
183 + {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-sm text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}
184 + </div>
185 + {showClose ? (
186 + <DialogPrimitive.Close className="absolute right-3 top-3 rounded-md p-1.5 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg" aria-label="Close">
187 + <X className="size-4" />
188 + </DialogPrimitive.Close>
189 + ) : null}
190 + {header ? <div className="shrink-0">{header}</div> : null}
191 + <div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", flush || desktopFlush ? "" : "px-6 pb-6", bodyClassName)}>{children}</div>
192 + {footer ? <div className="shrink-0 border-t border-border px-6 py-3">{footer}</div> : null}
193 + </DialogPrimitive.Content>
194 + </DialogPrimitive.Portal>
195 + </DialogPrimitive.Root>
196 + );
197 +}
198 +
199 +/* ------------------------------------------------------------------------------------------------ */
200 +
201 +export interface ActionSheetItem {
202 + key: string;
203 + label: React.ReactNode;
204 + icon?: React.ReactNode;
205 + hint?: React.ReactNode;
206 + onSelect: () => void;
207 + destructive?: boolean;
208 + disabled?: boolean;
209 + /** Renders a check mark (selection lists). */
210 + selected?: boolean;
211 +}
212 +
213 +export interface ActionSheetProps {
214 + open: boolean;
215 + onOpenChange: (open: boolean) => void;
216 + title?: React.ReactNode;
217 + description?: React.ReactNode;
218 + items: (ActionSheetItem | "separator")[];
219 + /** Optional content above the list (e.g. a preview of the message). */
220 + children?: React.ReactNode;
221 + cancelLabel?: string;
222 +}
223 +
224 +/**
225 + * iOS-style action list inside a bottom sheet. Use for long-press / "more" menus on phones.
226 + * On desktop callers should keep using DropdownMenu; `useIsMobile()` decides.
227 + */
228 +export function ActionSheet({ open, onOpenChange, title, description, items, children, cancelLabel = "Cancel" }: ActionSheetProps) {
229 + return (
230 + <BottomSheet open={open} onOpenChange={onOpenChange} title={title} description={description} hideTitle={!title} expandable={false} flush bodyClassName="px-3 pb-[max(12px,var(--sab))]">
231 + {children ? <div className="px-2 pb-3">{children}</div> : null}
232 + <div className="overflow-hidden rounded-2xl bg-bg-subtle">
233 + {items.map((it, i) =>
234 + it === "separator" ? (
235 + <div key={`sep-${i}`} className="h-2 bg-bg" />
236 + ) : (
237 + <button
238 + key={it.key}
239 + type="button"
240 + disabled={it.disabled}
241 + onClick={() => {
242 + onOpenChange(false);
243 + // Let the sheet start closing before running navigation-heavy handlers.
244 + setTimeout(it.onSelect, 10);
245 + }}
246 + className={cn(
247 + "flex min-h-[52px] w-full items-center gap-3 px-4 text-left text-[16px] transition-colors active:bg-bg-muted disabled:opacity-40 [&_svg]:size-[18px] [&_svg]:shrink-0",
248 + i > 0 && items[i - 1] !== "separator" && "border-t border-hairline",
249 + it.destructive ? "text-danger" : "text-fg",
250 + )}
251 + >
252 + <span className={cn("text-fg-muted", it.destructive && "text-danger")}>{it.icon}</span>
253 + <span className="min-w-0 flex-1 truncate">{it.label}</span>
254 + {it.hint ? <span className="text-[13px] text-fg-subtle">{it.hint}</span> : null}
255 + {it.selected ? <span className="text-accent">✓</span> : null}
256 + </button>
257 + ),
258 + )}
259 + </div>
260 + <button type="button" onClick={() => onOpenChange(false)} className="mt-2 flex min-h-[52px] w-full items-center justify-center rounded-2xl bg-bg-subtle text-[16px] font-semibold text-fg active:bg-bg-muted">
261 + {cancelLabel}
262 + </button>
263 + </BottomSheet>
264 + );
265 +}
modified src/db/index.ts +4 −1
@@ -1,6 +1,8 @@
1 1 import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
2 2 import { Pool } from "pg";
3 import * as schema from "./schema";
3 +import * as core from "./schema";
4 +import * as workspace from "./schema-workspace";
5 +const schema = { ...core, ...workspace };
4 6
5 7 export type Db = NodePgDatabase<typeof schema>;
6 8
@@ -46,3 +48,4 @@ export async function pingDb(): Promise<boolean> {
46 48 }
47 49
48 50 export * from "./schema";
51 +export * from "./schema-workspace";
added src/db/schema-workspace.ts +201 −0
@@ -0,0 +1,201 @@
1 +/**
2 + * Workspace tables added by the 2026-09 "AI operating system" upgrade:
3 + * projects (workspaces), project files (reusable context library), prompt library with variables,
4 + * user model labels, custom OpenAI-compatible endpoints and Arena votes/criteria.
5 + *
6 + * Kept in a separate file so parallel feature work never edits `schema.ts` concurrently.
7 + * `drizzle.config.ts` globs `schema*.ts`; `src/db/index.ts` re-exports everything.
8 + */
9 +import { pgTable, text, timestamp, boolean, integer, jsonb, index, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
10 +import { sql } from "drizzle-orm";
11 +import { users } from "./schema";
12 +
13 +const ts = (name: string) => timestamp(name, { withTimezone: true, mode: "date" });
14 +const now = () => sql`now()`;
15 +
16 +// ---------------------------------------------------------------------------
17 +// Projects / workspaces
18 +// ---------------------------------------------------------------------------
19 +export const projects = pgTable(
20 + "projects",
21 + {
22 + id: text("id").primaryKey(),
23 + userId: text("user_id")
24 + .notNull()
25 + .references(() => users.id, { onDelete: "cascade" }),
26 + name: text("name").notNull(),
27 + description: text("description"),
28 + /** Emoji or lucide icon name. */
29 + icon: text("icon"),
30 + color: text("color"),
31 + /** Default system instructions prepended to every chat in the project. */
32 + instructions: text("instructions"),
33 + /** Preferred model keys (first = default for new chats). */
34 + preferredModelKeys: jsonb("preferred_model_keys").$type<string[]>().notNull().default([]),
35 + /** Default generation settings for new chats. */
36 + defaultSettings: jsonb("default_settings").$type<Record<string, unknown>>().notNull().default({}),
37 + /** Free-form markdown notes. */
38 + notes: text("notes"),
39 + archived: boolean("archived").notNull().default(false),
40 + sortOrder: integer("sort_order").notNull().default(0),
41 + createdAt: ts("created_at").notNull().default(now()),
42 + updatedAt: ts("updated_at").notNull().default(now()),
43 + },
44 + (t) => [index("projects_user_idx").on(t.userId)],
45 +);
46 +
47 +/** Reusable files saved to a project (or to the user's global library when projectId is null). */
48 +export const projectFiles = pgTable(
49 + "project_files",
50 + {
51 + id: text("id").primaryKey(),
52 + userId: text("user_id")
53 + .notNull()
54 + .references(() => users.id, { onDelete: "cascade" }),
55 + projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
56 + kind: text("kind").notNull(), // image | pdf | text | code | csv | json | document
57 + name: text("name").notNull(),
58 + mimeType: text("mime_type").notNull(),
59 + sizeBytes: integer("size_bytes").notNull(),
60 + /** Base64 payload (same storage strategy as message_attachments). */
61 + dataBase64: text("data_base64").notNull(),
62 + width: integer("width"),
63 + height: integer("height"),
64 + /** Heuristic token estimate computed at upload. */
65 + estimatedTokens: integer("estimated_tokens"),
66 + /** Optional user description used when injecting into prompts. */
67 + description: text("description"),
68 + createdAt: ts("created_at").notNull().default(now()),
69 + },
70 + (t) => [index("project_files_user_idx").on(t.userId), index("project_files_project_idx").on(t.projectId)],
71 +);
72 +
73 +// ---------------------------------------------------------------------------
74 +// Prompt library (templates with {{variables}})
75 +// ---------------------------------------------------------------------------
76 +export const prompts = pgTable(
77 + "prompts",
78 + {
79 + id: text("id").primaryKey(),
80 + userId: text("user_id")
81 + .notNull()
82 + .references(() => users.id, { onDelete: "cascade" }),
83 + projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
84 + /** system | user | template | structured */
85 + kind: text("kind").notNull().default("user"),
86 + name: text("name").notNull(),
87 + description: text("description"),
88 + /** Body with `{{variable}}` placeholders. */
89 + content: text("content").notNull(),
90 + /** Declared variables (parsed from content, editable defaults). */
91 + variables: jsonb("variables").$type<{ name: string; label?: string; default?: string; required?: boolean; options?: string[] }[]>().notNull().default([]),
92 + /** For `structured`: the JSON schema to request. */
93 + schema: jsonb("schema").$type<Record<string, unknown> | null>(),
94 + folder: text("folder"),
95 + tags: jsonb("tags").$type<string[]>().notNull().default([]),
96 + favorite: boolean("favorite").notNull().default(false),
97 + defaultModelKey: text("default_model_key"),
98 + uses: integer("uses").notNull().default(0),
99 + lastUsedAt: ts("last_used_at"),
100 + createdAt: ts("created_at").notNull().default(now()),
101 + updatedAt: ts("updated_at").notNull().default(now()),
102 + },
103 + (t) => [index("prompts_user_idx").on(t.userId), index("prompts_user_folder_idx").on(t.userId, t.folder)],
104 +);
105 +
106 +// ---------------------------------------------------------------------------
107 +// Model intelligence — user labels & parameter presets scoped to capability sets
108 +// ---------------------------------------------------------------------------
109 +export const userModelLabels = pgTable(
110 + "user_model_labels",
111 + {
112 + userId: text("user_id")
113 + .notNull()
114 + .references(() => users.id, { onDelete: "cascade" }),
115 + modelKey: text("model_key").notNull(),
116 + label: text("label").notNull(),
117 + updatedAt: ts("updated_at").notNull().default(now()),
118 + },
119 + (t) => [primaryKey({ columns: [t.userId, t.modelKey] })],
120 +);
121 +
122 +// ---------------------------------------------------------------------------
123 +// Custom OpenAI-compatible endpoints (Ollama, LM Studio, vLLM, llama.cpp, MLX…)
124 +// ---------------------------------------------------------------------------
125 +export const customEndpoints = pgTable(
126 + "custom_endpoints",
127 + {
128 + id: text("id").primaryKey(),
129 + userId: text("user_id")
130 + .notNull()
131 + .references(() => users.id, { onDelete: "cascade" }),
132 + name: text("name").notNull(),
133 + /** Base URL up to and including `/v1` (e.g. http://localhost:11434/v1). */
134 + baseUrl: text("base_url").notNull(),
135 + /** Optional API key, AES-256-GCM envelope like provider_connections.encrypted_key. */
136 + encryptedKey: text("encrypted_key"),
137 + keyHint: text("key_hint"),
138 + /** Extra headers (values encrypted as a single JSON envelope). */
139 + encryptedHeaders: text("encrypted_headers"),
140 + /** Relative path used for discovery, default `/models`. Empty = manual models only. */
141 + modelsPath: text("models_path").notNull().default("/models"),
142 + /** Manually declared models when discovery is unavailable. */
143 + manualModels: jsonb("manual_models").$type<{ id: string; displayName?: string; contextTokens?: number; vision?: boolean; tools?: boolean; reasoning?: boolean }[]>().notNull().default([]),
144 + status: text("status").notNull().default("unverified"), // unverified | valid | invalid | error
145 + lastValidatedAt: ts("last_validated_at"),
146 + lastValidationError: text("last_validation_error"),
147 + lastLatencyMs: integer("last_latency_ms"),
148 + modelsAvailable: integer("models_available"),
149 + createdAt: ts("created_at").notNull().default(now()),
150 + updatedAt: ts("updated_at").notNull().default(now()),
151 + },
152 + (t) => [index("custom_endpoints_user_idx").on(t.userId)],
153 +);
154 +
155 +// ---------------------------------------------------------------------------
156 +// Arena — votes per criterion (ratings on arena_responses stay for compatibility)
157 +// ---------------------------------------------------------------------------
158 +export const arenaVotes = pgTable(
159 + "arena_votes",
160 + {
161 + id: text("id").primaryKey(),
162 + userId: text("user_id")
163 + .notNull()
164 + .references(() => users.id, { onDelete: "cascade" }),
165 + sessionId: text("session_id").notNull(),
166 + responseId: text("response_id").notNull(),
167 + modelKey: text("model_key").notNull(),
168 + /** best | accurate | writing | coding | value | fastest | custom:<slug> */
169 + criterion: text("criterion").notNull(),
170 + /** Task category the prompt was classified into (coding | research | writing | reasoning | general). */
171 + category: text("category"),
172 + createdAt: ts("created_at").notNull().default(now()),
173 + },
174 + (t) => [uniqueIndex("arena_votes_session_criterion_uq").on(t.sessionId, t.criterion), index("arena_votes_user_model_idx").on(t.userId, t.modelKey)],
175 +);
176 +
177 +// ---------------------------------------------------------------------------
178 +// Public share links for Arena sessions
179 +// ---------------------------------------------------------------------------
180 +export const sharedArenaSessions = pgTable(
181 + "shared_arena_sessions",
182 + {
183 + id: text("id").primaryKey(),
184 + sessionId: text("session_id").notNull(),
185 + userId: text("user_id")
186 + .notNull()
187 + .references(() => users.id, { onDelete: "cascade" }),
188 + snapshot: jsonb("snapshot").$type<Record<string, unknown>>().notNull(),
189 + isPublic: boolean("is_public").notNull().default(true),
190 + viewCount: integer("view_count").notNull().default(0),
191 + createdAt: ts("created_at").notNull().default(now()),
192 + revokedAt: ts("revoked_at"),
193 + },
194 + (t) => [index("shared_arena_session_idx").on(t.sessionId)],
195 +);
196 +
197 +export type Project = typeof projects.$inferSelect;
198 +export type ProjectFile = typeof projectFiles.$inferSelect;
199 +export type Prompt = typeof prompts.$inferSelect;
200 +export type CustomEndpoint = typeof customEndpoints.$inferSelect;
201 +export type ArenaVote = typeof arenaVotes.$inferSelect;
modified src/db/schema.ts +3 −0
@@ -274,6 +274,8 @@ export const conversations = pgTable(
274 274 title: text("title").notNull().default("New chat"),
275 275 titleSource: text("title_source").notNull().default("auto"), // auto | user
276 276 folderId: text("folder_id").references(() => folders.id, { onDelete: "set null" }),
277 + /** Workspace the conversation belongs to (see schema-workspace.ts `projects`). No FK: projects may be deleted independently. */
278 + projectId: text("project_id"),
277 279 pinned: boolean("pinned").notNull().default(false),
278 280 archived: boolean("archived").notNull().default(false),
279 281 /** Last model used (`provider/modelId`). */
@@ -296,6 +298,7 @@ export const conversations = pgTable(
296 298 index("conversations_user_updated_idx").on(t.userId, t.updatedAt),
297 299 index("conversations_user_folder_idx").on(t.userId, t.folderId),
298 300 index("conversations_user_model_idx").on(t.userId, t.modelKey),
301 + index("conversations_user_project_idx").on(t.userId, t.projectId),
299 302 ],
300 303 );
301 304
added src/lib/ai/registry/user-models.ts +22 −0
@@ -0,0 +1,22 @@
1 +import "server-only";
2 +import { and, eq } from "drizzle-orm";
3 +import { getDb, userModelLabels } from "@/db";
4 +
5 +/** User-defined labels ("My research model") keyed by model key. */
6 +export async function modelLabels(userId: string): Promise<Record<string, string>> {
7 + const rows = await getDb().select({ modelKey: userModelLabels.modelKey, label: userModelLabels.label }).from(userModelLabels).where(eq(userModelLabels.userId, userId));
8 + return Object.fromEntries(rows.map((r) => [r.modelKey, r.label]));
9 +}
10 +
11 +export async function setModelLabel(userId: string, modelKey: string, label: string | null): Promise<void> {
12 + const db = getDb();
13 + const clean = label?.trim().slice(0, 60) ?? "";
14 + if (!clean) {
15 + await db.delete(userModelLabels).where(and(eq(userModelLabels.userId, userId), eq(userModelLabels.modelKey, modelKey)));
16 + return;
17 + }
18 + await db
19 + .insert(userModelLabels)
20 + .values({ userId, modelKey, label: clean })
21 + .onConflictDoUpdate({ target: [userModelLabels.userId, userModelLabels.modelKey], set: { label: clean, updatedAt: new Date() } });
22 +}
added src/lib/client/hooks.ts +295 −0
@@ -0,0 +1,295 @@
1 +"use client";
2 +import * as React from "react";
3 +
4 +/** Tailwind `md` breakpoint — below it the app renders its mobile shell. */
5 +export const MOBILE_QUERY = "(max-width: 767.98px)";
6 +export const TABLET_QUERY = "(min-width: 768px) and (max-width: 1023.98px)";
7 +export const COARSE_QUERY = "(pointer: coarse)";
8 +export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
9 +
10 +function subscribe(query: string) {
11 + return (cb: () => void) => {
12 + const mq = window.matchMedia(query);
13 + mq.addEventListener("change", cb);
14 + return () => mq.removeEventListener("change", cb);
15 + };
16 +}
17 +
18 +/** SSR-safe media query. Returns `fallback` on the server and on the first client render. */
19 +export function useMediaQuery(query: string, fallback = false): boolean {
20 + return React.useSyncExternalStore(
21 + subscribe(query),
22 + () => window.matchMedia(query).matches,
23 + () => fallback,
24 + );
25 +}
26 +
27 +export function useIsMobile(): boolean {
28 + return useMediaQuery(MOBILE_QUERY);
29 +}
30 +export function useIsTablet(): boolean {
31 + return useMediaQuery(TABLET_QUERY);
32 +}
33 +export function useIsCoarsePointer(): boolean {
34 + return useMediaQuery(COARSE_QUERY);
35 +}
36 +export function usePrefersReducedMotion(): boolean {
37 + return useMediaQuery(REDUCED_MOTION_QUERY);
38 +}
39 +
40 +/** True after hydration — use to gate `resolvedTheme`, `window`, etc. */
41 +export function useMounted(): boolean {
42 + return React.useSyncExternalStore(
43 + () => () => {},
44 + () => true,
45 + () => false,
46 + );
47 +}
48 +
49 +/**
50 + * Tracks the on-screen keyboard through `visualViewport` and publishes the hidden
51 + * height as the CSS variable `--kb` on <html>. Layouts use `.h-app`
52 + * (`calc(100dvh - var(--kb))`) so the composer always stays above the keyboard.
53 + * Returns the current inset in px.
54 + */
55 +export function useKeyboardInset(): number {
56 + const [inset, setInset] = React.useState(0);
57 + React.useEffect(() => {
58 + const vv = window.visualViewport;
59 + if (!vv) return;
60 + let raf = 0;
61 + const update = () => {
62 + cancelAnimationFrame(raf);
63 + raf = requestAnimationFrame(() => {
64 + // Difference between the layout viewport and the visual viewport = keyboard (iOS/Android).
65 + const hidden = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
66 + // Ignore tiny deltas from browser chrome animations.
67 + const kb = hidden > 60 ? hidden : 0;
68 + document.documentElement.style.setProperty("--kb", `${kb}px`);
69 + document.documentElement.toggleAttribute("data-keyboard", kb > 0);
70 + setInset(kb);
71 + // iOS scrolls the page when an input focuses; snap it back so fixed elements line up.
72 + if (kb > 0 && window.scrollY !== 0) window.scrollTo(0, 0);
73 + });
74 + };
75 + vv.addEventListener("resize", update);
76 + vv.addEventListener("scroll", update);
77 + update();
78 + return () => {
79 + vv.removeEventListener("resize", update);
80 + vv.removeEventListener("scroll", update);
81 + cancelAnimationFrame(raf);
82 + document.documentElement.style.setProperty("--kb", "0px");
83 + document.documentElement.removeAttribute("data-keyboard");
84 + };
85 + }, []);
86 + return inset;
87 +}
88 +
89 +export interface LongPressOptions {
90 + /** ms before the press fires (default 420). */
91 + delay?: number;
92 + /** Movement tolerance in px before the press is cancelled (default 10). */
93 + tolerance?: number;
94 + onLongPress: (e: { x: number; y: number; target: EventTarget | null }) => void;
95 + /** Optional plain-click handler (fires only when no long press happened). */
96 + onClick?: (e: React.PointerEvent) => void;
97 + disabled?: boolean;
98 +}
99 +
100 +/**
101 + * Long-press gesture for touch surfaces. Returns pointer handlers to spread on the element.
102 + * Also suppresses the native context menu so iOS doesn't show its own callout.
103 + */
104 +export function useLongPress({ delay = 420, tolerance = 10, onLongPress, onClick, disabled }: LongPressOptions) {
105 + const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
106 + const start = React.useRef<{ x: number; y: number } | null>(null);
107 + const fired = React.useRef(false);
108 +
109 + const clear = React.useCallback(() => {
110 + if (timer.current) clearTimeout(timer.current);
111 + timer.current = null;
112 + start.current = null;
113 + }, []);
114 +
115 + React.useEffect(() => clear, [clear]);
116 +
117 + if (disabled) return {};
118 + return {
119 + onPointerDown: (e: React.PointerEvent) => {
120 + if (e.pointerType === "mouse" && e.button !== 0) return;
121 + fired.current = false;
122 + start.current = { x: e.clientX, y: e.clientY };
123 + const target = e.target;
124 + timer.current = setTimeout(() => {
125 + fired.current = true;
126 + if (navigator.vibrate) navigator.vibrate(8);
127 + onLongPress({ x: start.current?.x ?? e.clientX, y: start.current?.y ?? e.clientY, target });
128 + clear();
129 + }, delay);
130 + },
131 + onPointerMove: (e: React.PointerEvent) => {
132 + if (!start.current) return;
133 + if (Math.abs(e.clientX - start.current.x) > tolerance || Math.abs(e.clientY - start.current.y) > tolerance) clear();
134 + },
135 + onPointerUp: (e: React.PointerEvent) => {
136 + const wasPending = Boolean(timer.current);
137 + clear();
138 + if (wasPending && !fired.current) onClick?.(e);
139 + },
140 + onPointerCancel: clear,
141 + onPointerLeave: clear,
142 + onContextMenu: (e: React.MouseEvent) => {
143 + // Touch long-press triggers contextmenu on Android; we already handle it.
144 + if (fired.current || timer.current) e.preventDefault();
145 + },
146 + };
147 +}
148 +
149 +export interface SwipeOptions {
150 + onSwipeLeft?: () => void;
151 + onSwipeRight?: () => void;
152 + onSwipeDown?: () => void;
153 + onSwipeUp?: () => void;
154 + /** px of travel needed (default 56). */
155 + threshold?: number;
156 + /** Only start a horizontal swipe when the touch begins within `edge` px of the left edge. */
157 + edge?: number;
158 + disabled?: boolean;
159 + /** Ignore swipes whose start target (or ancestor) matches this selector, e.g. horizontally scrollable areas. */
160 + ignoreSelector?: string;
161 +}
162 +
163 +/**
164 + * Attaches touch swipe recognition to a DOM element (via ref). Direction is locked after the
165 + * first 12px so vertical scrolling is never hijacked.
166 + */
167 +export function useSwipe<T extends HTMLElement>(ref: React.RefObject<T | null>, { onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold = 56, edge, disabled, ignoreSelector }: SwipeOptions) {
168 + React.useEffect(() => {
169 + const el = ref.current;
170 + if (!el || disabled) return;
171 + let sx = 0, sy = 0, dx = 0, dy = 0, active = false, axis: "x" | "y" | null = null;
172 + const onStart = (e: TouchEvent) => {
173 + if (e.touches.length !== 1) return;
174 + const t = e.touches[0];
175 + if (edge !== undefined && t.clientX > edge) return;
176 + if (ignoreSelector && (e.target as Element | null)?.closest?.(ignoreSelector)) return;
177 + sx = t.clientX; sy = t.clientY; dx = 0; dy = 0; active = true; axis = null;
178 + };
179 + const onMove = (e: TouchEvent) => {
180 + if (!active) return;
181 + const t = e.touches[0];
182 + dx = t.clientX - sx; dy = t.clientY - sy;
183 + if (!axis && (Math.abs(dx) > 12 || Math.abs(dy) > 12)) axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
184 + };
185 + const onEnd = () => {
186 + if (!active) return;
187 + active = false;
188 + if (axis === "x" && Math.abs(dx) >= threshold) (dx < 0 ? onSwipeLeft : onSwipeRight)?.();
189 + else if (axis === "y" && Math.abs(dy) >= threshold) (dy < 0 ? onSwipeUp : onSwipeDown)?.();
190 + };
191 + el.addEventListener("touchstart", onStart, { passive: true });
192 + el.addEventListener("touchmove", onMove, { passive: true });
193 + el.addEventListener("touchend", onEnd);
194 + el.addEventListener("touchcancel", onEnd);
195 + return () => {
196 + el.removeEventListener("touchstart", onStart);
197 + el.removeEventListener("touchmove", onMove);
198 + el.removeEventListener("touchend", onEnd);
199 + el.removeEventListener("touchcancel", onEnd);
200 + };
201 + }, [ref, onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold, edge, disabled, ignoreSelector]);
202 +}
203 +
204 +/** Persisted boolean/string/JSON state in localStorage (SSR-safe: initial render uses `initial`). */
205 +export function useLocalStorage<T>(key: string, initial: T): [T, (v: T | ((prev: T) => T)) => void] {
206 + const [value, setValue] = React.useState<T>(initial);
207 + const loaded = React.useRef(false);
208 + React.useEffect(() => {
209 + try {
210 + const raw = window.localStorage.getItem(key);
211 + // eslint-disable-next-line react-hooks/set-state-in-effect
212 + if (raw !== null) setValue(JSON.parse(raw) as T);
213 + } catch {
214 + /* ignore */
215 + }
216 + loaded.current = true;
217 + }, [key]);
218 + const set = React.useCallback(
219 + (v: T | ((prev: T) => T)) => {
220 + setValue((prev) => {
221 + const next = typeof v === "function" ? (v as (p: T) => T)(prev) : v;
222 + try {
223 + window.localStorage.setItem(key, JSON.stringify(next));
224 + } catch {
225 + /* quota */
226 + }
227 + return next;
228 + });
229 + },
230 + [key],
231 + );
232 + return [value, set];
233 +}
234 +
235 +/** Debounced value. */
236 +export function useDebounced<T>(value: T, ms = 150): T {
237 + const [v, setV] = React.useState(value);
238 + React.useEffect(() => {
239 + const t = setTimeout(() => setV(value), ms);
240 + return () => clearTimeout(t);
241 + }, [value, ms]);
242 + return v;
243 +}
244 +
245 +/** Copy-to-clipboard with a transient `copied` flag. */
246 +export function useCopy(resetMs = 1400): [boolean, (text: string) => Promise<void>] {
247 + const [copied, setCopied] = React.useState(false);
248 + const copy = React.useCallback(
249 + async (text: string) => {
250 + try {
251 + await navigator.clipboard.writeText(text);
252 + setCopied(true);
253 + setTimeout(() => setCopied(false), resetMs);
254 + } catch {
255 + /* ignore */
256 + }
257 + },
258 + [resetMs],
259 + );
260 + return [copied, copy];
261 +}
262 +
263 +/** Whether the document is displayed as an installed PWA. */
264 +export function useStandalone(): boolean {
265 + return useMediaQuery("(display-mode: standalone)");
266 +}
267 +
268 +/** Tracks the active index of a `.snap-row` carousel and lets you scroll to a panel. */
269 +export function useSnapCarousel<T extends HTMLElement>(count: number) {
270 + const ref = React.useRef<T | null>(null);
271 + const [index, setIndex] = React.useState(0);
272 + React.useEffect(() => {
273 + const el = ref.current;
274 + if (!el) return;
275 + let raf = 0;
276 + const onScroll = () => {
277 + cancelAnimationFrame(raf);
278 + raf = requestAnimationFrame(() => {
279 + const w = el.clientWidth || 1;
280 + setIndex(Math.max(0, Math.min(count - 1, Math.round(el.scrollLeft / w))));
281 + });
282 + };
283 + el.addEventListener("scroll", onScroll, { passive: true });
284 + return () => {
285 + el.removeEventListener("scroll", onScroll);
286 + cancelAnimationFrame(raf);
287 + };
288 + }, [count]);
289 + const scrollTo = React.useCallback((i: number, smooth = true) => {
290 + const el = ref.current;
291 + if (!el) return;
292 + el.scrollTo({ left: i * el.clientWidth, behavior: smooth ? "smooth" : "auto" });
293 + }, []);
294 + return { ref, index, scrollTo };
295 +}
added src/lib/client/router.ts +215 −0
@@ -0,0 +1,215 @@
1 +import type { PolyModel, ProviderId } from "@/lib/client/types";
2 +import { estimateTextTokens, estimateAttachmentTokens, estimateCost, type AttachmentLike } from "./tokens";
3 +
4 +/**
5 + * PolyLLM Smart Router — transparent, client-side model recommendation.
6 + *
7 + * Pure function over the user's usable models: no network, no hidden state. The UI always shows
8 + * the recommendation, the reasons and the estimated cost before anything is sent, and never
9 + * silently upgrades to an expensive model (see `budgetCap`).
10 + */
11 +export type RouterMode = "balanced" | "quality" | "fastest" | "cheapest" | "coding" | "research";
12 +
13 +export const ROUTER_MODES: { value: RouterMode; label: string; description: string }[] = [
14 + { value: "balanced", label: "Balanced", description: "Good quality at a sensible price" },
15 + { value: "quality", label: "Best quality", description: "Strongest reasoning, cost secondary" },
16 + { value: "fastest", label: "Fastest", description: "Lowest latency tiers" },
17 + { value: "cheapest", label: "Lowest cost", description: "Cheapest model that fits the task" },
18 + { value: "coding", label: "Coding", description: "Code generation, review and debugging" },
19 + { value: "research", label: "Research", description: "Long context, reasoning, web search" },
20 +];
21 +
22 +export type TaskType = "coding" | "research" | "writing" | "reasoning" | "analysis" | "vision" | "translation" | "chat";
23 +
24 +export interface PromptAnalysis {
25 + task: TaskType;
26 + inputTokens: number;
27 + needsVision: boolean;
28 + needsFiles: boolean;
29 + needsLongContext: boolean;
30 + needsReasoning: boolean;
31 + needsWeb: boolean;
32 + needsJson: boolean;
33 + signals: string[];
34 +}
35 +
36 +const CODE_RE = /```|\bfunction\b|\bconst\b|\bimport\b|\bdef\b|\bclass\b|\breturn\b|=>|\bSELECT\b|\bnpm\b|\bpip\b|stack ?trace|exception|bug|refactor|typescript|python|rust|\bsql\b|regex|compile|debug/i;
37 +const RESEARCH_RE = /research|sources?|cite|citation|literature|paper|study|studies|latest|news|today|current|2026|compare .* (and|vs)|pros and cons|state of the art|survey/i;
38 +const REASON_RE = /prove|proof|derive|step[- ]by[- ]step|theorem|puzzle|riddle|optimi[sz]e|strategy|plan (a|the)|trade-?offs?|why does|analy[sz]e deeply|chain of thought|logic/i;
39 +const WRITING_RE = /write|draft|essay|blog|email|letter|story|poem|rewrite|paraphrase|tone|copy(writing)?|headline|slogan|summar/i;
40 +const ANALYSIS_RE = /csv|spreadsheet|dataset|data\b|table|statistics|trend|chart|forecast|financial|revenue|kpi|metrics/i;
41 +const TRANSLATE_RE = /translate|traduis|traduction|in (french|spanish|german|italian|japanese|chinese|english)/i;
42 +const JSON_RE = /\bjson\b|schema|structured output|as a list of objects|key[- ]value/i;
43 +const WEB_RE = /search the web|look up|latest|news|today|this week|current price|weather|stock|who won|release date/i;
44 +
45 +export function analyzePrompt(text: string, attachments: AttachmentLike[] = [], opts: { historyTokens?: number; systemPrompt?: string | null; webSearch?: boolean; responseFormat?: boolean } = {}): PromptAnalysis {
46 + const signals: string[] = [];
47 + const t = text ?? "";
48 + const attTokens = attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0);
49 + const inputTokens = estimateTextTokens(t) + estimateTextTokens(opts.systemPrompt ?? "") + attTokens + (opts.historyTokens ?? 0);
50 + const needsVision = attachments.some((a) => a.kind === "image");
51 + const needsFiles = attachments.some((a) => a.kind === "pdf");
52 + const needsLongContext = inputTokens > 60_000;
53 + const needsWeb = Boolean(opts.webSearch) || WEB_RE.test(t);
54 + const needsJson = Boolean(opts.responseFormat) || JSON_RE.test(t);
55 + if (needsVision) signals.push("image attached");
56 + if (needsFiles) signals.push("PDF attached");
57 + if (needsLongContext) signals.push(`${Math.round(inputTokens / 1000)}K tokens of context`);
58 + if (needsWeb) signals.push("fresh information");
59 + if (needsJson) signals.push("structured output");
60 +
61 + let task: TaskType = "chat";
62 + const codeScore = (t.match(CODE_RE) ?? []).length + (t.includes("```") ? 3 : 0);
63 + if (attachments.some((a) => a.kind === "code")) signals.push("code file");
64 + if (codeScore >= 2 || attachments.some((a) => a.kind === "code")) task = "coding";
65 + else if (TRANSLATE_RE.test(t)) task = "translation";
66 + else if (REASON_RE.test(t)) task = "reasoning";
67 + else if (RESEARCH_RE.test(t)) task = "research";
68 + else if (ANALYSIS_RE.test(t) || attachments.some((a) => a.kind === "csv" || a.kind === "json")) task = "analysis";
69 + else if (WRITING_RE.test(t)) task = "writing";
70 + else if (needsVision) task = "vision";
71 + const needsReasoning = task === "reasoning" || task === "coding" || task === "analysis" || (task === "research" && t.length > 400) || t.length > 1500;
72 + if (task !== "chat") signals.unshift(`${task} task`);
73 + if (needsReasoning && task !== "reasoning") signals.push("benefits from reasoning");
74 + return { task, inputTokens, needsVision, needsFiles, needsLongContext, needsReasoning, needsWeb, needsJson, signals };
75 +}
76 +
77 +export interface RouteCandidate {
78 + model: PolyModel;
79 + score: number;
80 + estimatedUsd: number | null;
81 + reasons: string[];
82 +}
83 +
84 +export interface RouteResult {
85 + analysis: PromptAnalysis;
86 + recommended: RouteCandidate | null;
87 + alternatives: RouteCandidate[];
88 + mode: RouterMode;
89 + /** True when the recommendation exceeds the cap and needs explicit confirmation. */
90 + needsConfirmation: boolean;
91 +}
92 +
93 +const FAST_RE = /flash|mini|nano|haiku|fast|lite|non-reasoning|instant|turbo|small|ministral|8b|9b|gemma|gpt-oss-20b|llama-3\.1-8b/i;
94 +const FRONTIER_RE = /opus|fable|gpt-5\.5|gpt-6|pro\b|grok-4(\.\d+)?(?!-fast)|large|k3|deepseek-v4-pro|magistral-medium/i;
95 +const CODING_RE = /codestral|code|coder|devstral|gpt-5\.5|sonnet|opus|fable|k2\.7|deepseek/i;
96 +const OPEN_RE = /llama|qwen|gemma|gpt-oss|mistral-small|mixtral|deepseek|kimi|k2|k3|glm|command/i;
97 +
98 +const PROVIDER_LATENCY_BONUS: Partial<Record<ProviderId, number>> = { cerebras: 3, xai: 0.5, gemini: 0.5, openrouter: -0.3 };
99 +
100 +/** Median USD per 1M output tokens across usable models — used as the "expensive" reference. */
101 +function medianOut(models: PolyModel[]): number {
102 + const v = models.map((m) => m.pricing?.outputPerMillion).filter((x): x is number => typeof x === "number").sort((a, b) => a - b);
103 + return v.length ? v[Math.floor(v.length / 2)] : 5;
104 +}
105 +
106 +export function routeModels(models: PolyModel[], analysis: PromptAnalysis, mode: RouterMode = "balanced", opts: { budgetCapUsd?: number; favorites?: Set<string>; expectedOutputTokens?: number } = {}): RouteResult {
107 + const expectedOut = opts.expectedOutputTokens ?? (analysis.task === "writing" || analysis.task === "coding" ? 1200 : 600);
108 + const usable = models.filter((m) => m.status !== "deprecated" && m.capabilities.text !== false);
109 + const median = medianOut(usable);
110 + const cap = opts.budgetCapUsd ?? 0.5;
111 +
112 + const scored: RouteCandidate[] = usable
113 + .filter((m) => {
114 + if (analysis.needsVision && !m.capabilities.vision) return false;
115 + if (analysis.needsFiles && !m.capabilities.files) return false;
116 + const ctx = m.limits?.contextTokens ?? 128_000;
117 + if (analysis.inputTokens + expectedOut > ctx * 0.92) return false;
118 + return true;
119 + })
120 + .map((m) => {
121 + const reasons: string[] = [];
122 + let score = 0;
123 + const id = `${m.id} ${m.displayName}`;
124 + const fast = FAST_RE.test(id);
125 + const frontier = FRONTIER_RE.test(id);
126 + const outPrice = m.pricing?.outputPerMillion ?? median;
127 + const inPrice = m.pricing?.inputPerMillion ?? median / 4;
128 + const cost = estimateCost(m, analysis.inputTokens, expectedOut).usd;
129 + const cheapness = Math.max(0, 1 - outPrice / (median * 2)); // 0..1
130 + const ctx = m.limits?.contextTokens ?? 0;
131 +
132 + // Capability fit
133 + if (analysis.needsReasoning && m.capabilities.reasoning) {
134 + score += 3;
135 + reasons.push("reasoning");
136 + } else if (analysis.needsReasoning) score -= 2;
137 + if (analysis.needsWeb && m.capabilities.webSearch) {
138 + score += 2.5;
139 + reasons.push("web search");
140 + } else if (analysis.needsWeb) score -= 1;
141 + if (analysis.needsJson && m.capabilities.structuredOutput) {
142 + score += 1.5;
143 + reasons.push("JSON schema");
144 + }
145 + if (analysis.needsVision) reasons.push("vision");
146 + if (analysis.needsLongContext && ctx >= 400_000) {
147 + score += 2.5;
148 + reasons.push(`${Math.round(ctx / 1000)}K context`);
149 + } else if (ctx >= 1_000_000 && (mode === "research" || analysis.task === "research")) {
150 + score += 1;
151 + reasons.push("1M context");
152 + }
153 + if ((analysis.task === "coding" || mode === "coding") && CODING_RE.test(id)) {
154 + score += 2;
155 + reasons.push("strong at code");
156 + }
157 + if (analysis.task === "translation" && fast) score += 1;
158 + if (analysis.task === "chat" && fast) score += 1.5;
159 +
160 + // Tier
161 + if (frontier) {
162 + if (mode === "quality" || analysis.task === "reasoning" || analysis.task === "research") {
163 + score += 3;
164 + reasons.push("frontier quality");
165 + } else if (mode === "balanced" && analysis.needsReasoning) score += 1;
166 + else if (mode === "cheapest" || mode === "fastest") score -= 3;
167 + }
168 + if (fast) {
169 + if (mode === "fastest") {
170 + score += 3.5;
171 + reasons.push("fast tier");
172 + } else if (mode === "cheapest") score += 1.5;
173 + else if (mode === "quality") score -= 2;
174 + else if (mode === "balanced" && !analysis.needsReasoning) {
175 + score += 1;
176 + reasons.push("fast");
177 + }
178 + }
179 + score += (PROVIDER_LATENCY_BONUS[m.provider] ?? 0) * (mode === "fastest" ? 1 : 0.2);
180 +
181 + // Cost
182 + const costWeight = mode === "cheapest" ? 5 : mode === "balanced" ? 2.2 : mode === "quality" ? 0.4 : mode === "fastest" ? 1 : 1.5;
183 + score += cheapness * costWeight;
184 + if (cheapness > 0.7) reasons.push(`cheap (${inPrice.toFixed(2)}/${outPrice.toFixed(2)} per 1M)`);
185 + if (cost !== null && cost > cap && mode !== "quality") score -= 2;
186 +
187 + // Preference & hygiene
188 + if (opts.favorites?.has(m.key)) {
189 + score += 0.8;
190 + reasons.push("favorite");
191 + }
192 + if (m.status === "preview") score -= 0.3;
193 + if (m.provider === "openrouter") score -= 0.6; // prefer native connections when the same model exists
194 + if (OPEN_RE.test(id) && mode === "quality") score -= 0.5;
195 +
196 + return { model: m, score, estimatedUsd: cost, reasons: dedupe(reasons).slice(0, 3) };
197 + })
198 + .sort((a, b) => b.score - a.score);
199 +
200 + const recommended = scored[0] ?? null;
201 + const alternatives = scored.slice(1, 4);
202 + const needsConfirmation = Boolean(recommended?.estimatedUsd && recommended.estimatedUsd > cap);
203 + return { analysis, recommended, alternatives, mode, needsConfirmation };
204 +}
205 +
206 +function dedupe<T>(arr: T[]): T[] {
207 + return [...new Set(arr)];
208 +}
209 +
210 +/** Human explanation: "Strong reasoning + 1M context". */
211 +export function explainRoute(c: RouteCandidate | null): string {
212 + if (!c) return "No compatible model is connected.";
213 + if (!c.reasons.length) return "Best overall fit for this prompt.";
214 + return c.reasons.map((r, i) => (i === 0 ? r.charAt(0).toUpperCase() + r.slice(1) : r)).join(" + ");
215 +}
added src/lib/client/tokens.ts +90 −0
@@ -0,0 +1,90 @@
1 +import type { PolyModel } from "@/lib/client/types";
2 +
3 +/**
4 + * Client-side token & cost estimation. Heuristic (≈ 4 chars/token for English prose, ~3.2 for code)
5 + * — precise enough for "≈ $0.08" hints; the server records exact provider usage afterwards.
6 + */
7 +export function estimateTextTokens(text: string): number {
8 + if (!text) return 0;
9 + const codeish = /[{}();=<>[\]]/.test(text) && (text.match(/\n/g)?.length ?? 0) > 2;
10 + const perToken = codeish ? 3.2 : 4.0;
11 + // CJK scripts are ~1 token per character.
12 + const cjk = (text.match(/[぀-ヿ㐀-䶿一-鿿가-힯]/g) ?? []).length;
13 + return Math.ceil((text.length - cjk) / perToken + cjk);
14 +}
15 +
16 +export interface AttachmentLike {
17 + kind: string;
18 + sizeBytes: number;
19 + width?: number | null;
20 + height?: number | null;
21 +}
22 +
23 +/** Rough per-attachment token cost (images follow the common 512px-tile heuristic; text ≈ bytes / 4; PDFs ≈ 1.5K / page ≈ bytes/45). */
24 +export function estimateAttachmentTokens(a: AttachmentLike): number {
25 + if (a.kind === "image") {
26 + const w = a.width ?? 1024;
27 + const h = a.height ?? 1024;
28 + const tiles = Math.ceil(Math.min(w, 2048) / 512) * Math.ceil(Math.min(h, 2048) / 512);
29 + return 85 + tiles * 170;
30 + }
31 + if (a.kind === "pdf") return Math.max(300, Math.ceil(a.sizeBytes / 45));
32 + return Math.ceil(a.sizeBytes / 4);
33 +}
34 +
35 +export interface ContextEstimate {
36 + /** Tokens in the history that will be replayed. */
37 + history: number;
38 + /** Tokens in the draft + attachments + system prompt. */
39 + draft: number;
40 + total: number;
41 + contextTokens: number | null;
42 + /** 0..1 or null when the model has no known limit. */
43 + ratio: number | null;
44 + level: "ok" | "warn" | "critical" | "over";
45 +}
46 +
47 +export function estimateContext(opts: { historyText: string[]; historyAttachments?: AttachmentLike[]; draft: string; attachments?: AttachmentLike[]; systemPrompt?: string | null; model?: PolyModel | null; expectedOutput?: number }): ContextEstimate {
48 + const history = opts.historyText.reduce((n, t) => n + estimateTextTokens(t), 0) + (opts.historyAttachments ?? []).reduce((n, a) => n + estimateAttachmentTokens(a), 0) + opts.historyText.length * 4;
49 + const draft = estimateTextTokens(opts.draft) + (opts.attachments ?? []).reduce((n, a) => n + estimateAttachmentTokens(a), 0) + estimateTextTokens(opts.systemPrompt ?? "");
50 + const total = history + draft;
51 + const contextTokens = opts.model?.limits?.contextTokens ?? null;
52 + const ratio = contextTokens ? (total + (opts.expectedOutput ?? 0)) / contextTokens : null;
53 + const level = ratio === null ? "ok" : ratio >= 1 ? "over" : ratio >= 0.95 ? "critical" : ratio >= 0.8 ? "warn" : "ok";
54 + return { history, draft, total, contextTokens, ratio, level };
55 +}
56 +
57 +export interface CostEstimate {
58 + inputTokens: number;
59 + outputTokens: number;
60 + /** null when the model has no public pricing. */
61 + usd: number | null;
62 + inputUsd: number | null;
63 + outputUsd: number | null;
64 +}
65 +
66 +/** Estimate a request cost for a model: input tokens known, output tokens assumed (default 600, or maxTokens when smaller). */
67 +export function estimateCost(model: PolyModel | null | undefined, inputTokens: number, outputTokens = 600): CostEstimate {
68 + const p = model?.pricing;
69 + if (!p || (p.inputPerMillion === undefined && p.outputPerMillion === undefined)) return { inputTokens, outputTokens, usd: null, inputUsd: null, outputUsd: null };
70 + let inRate = p.inputPerMillion ?? 0;
71 + let outRate = p.outputPerMillion ?? 0;
72 + if (p.longContext && inputTokens > p.longContext.thresholdTokens) {
73 + inRate = p.longContext.inputPerMillion ?? inRate;
74 + outRate = p.longContext.outputPerMillion ?? outRate;
75 + }
76 + const inputUsd = (inputTokens / 1_000_000) * inRate;
77 + const outputUsd = (outputTokens / 1_000_000) * outRate;
78 + return { inputTokens, outputTokens, usd: inputUsd + outputUsd, inputUsd, outputUsd };
79 +}
80 +
81 +/** Compact "~22K tokens · ≈ $0.08" label. */
82 +export function formatEstimate(tokens: number, usd: number | null): string {
83 + const t = tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M` : tokens >= 1000 ? `${Math.round(tokens / 1000)}K` : String(tokens);
84 + if (usd === null) return `~${t} tokens`;
85 + const u = usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`;
86 + return `~${t} tokens · ≈ ${u}`;
87 +}
88 +
89 +/** Above this estimated cost the composer asks for confirmation before sending. */
90 +export const COST_CONFIRM_THRESHOLD_USD = 1.0;
modified src/lib/client/types.ts +3 −0
@@ -5,11 +5,14 @@ export type { PublicMessage, StoredPart } from "@/lib/chat/service";
5 5 export type { PublicConnection } from "@/lib/providers/keys";
6 6 export type { GenerationSettingsInput, ChatRequestInput } from "@/lib/chat/schemas";
7 7 export type { ModelPreset, PromptPreset, Folder, UserPreferences, ArenaSession, ArenaResponse } from "@/db/schema";
8 +export type { Project, ProjectFile, Prompt, CustomEndpoint, ArenaVote } from "@/db/schema-workspace";
8 9
9 10 export interface ModelsResponse {
10 11 models: import("@/lib/ai/core/types").PolyModel[];
11 12 favorites: string[];
12 13 recents: string[];
14 + /** User labels per model key. */
15 + labels: Record<string, string>;
13 16 connectedProviders: import("@/lib/ai/core/types").ProviderId[];
14 17 }
15 18
16 19