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>
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 | |