SPB Git

App: Next.js 16 UI (chat, model lab, compare, research, memory, projects, usage, settings, images, PWA), tools, research engine, docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
spb committed (Nov 1, 2026) parent 7d61c36 Browse files

122 changed files +4,931 −379

122 changed files
added .env.example +12 −0
@@ -0,0 +1,12 @@
1 +# Chat SPBoucher AI — copy to .env.local (dev) or .env.production (node). Never commit real values.
2 +DATABASE_URL=postgres://chat_spb:CHANGE_ME@127.0.0.1:5432/chat_spboucher
3 +SESSION_SECRET=CHANGE_ME_64_hex_chars # openssl rand -hex 32
4 +DATA_DIR=./data # uploads + generated files (mode 600)
5 +OPENAI_API_KEY=
6 +ANTHROPIC_API_KEY=
7 +TAVILY_API_KEY= # optional: web search / page extraction tools
8 +PYTHON_BIN=python3 # sandboxed python tool
9 +APP_URL=https://chat.spboucher.ai
10 +SESSION_TTL_DAYS=30
11 +MAX_UPLOAD_MB=50
12 +BENCHMARKS_DIR=./benchmarks
added AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +## This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added README.md +62 −0
@@ -0,0 +1,62 @@
1 +# Chat SPBoucher AI
2 +
3 +Interface privée, multi-modèles et multimodale de Simon-Pierre Boucher — **https://chat.spboucher.ai** — déployée sur le cluster MacLustr (nœud M4M64c, PostgreSQL 17 + pgvector, MacLustr Tunnel via BHS64).
4 +
5 +> Un seul propriétaire. Tous les modèles OpenAI et Anthropic **vérifiés par requête réelle**. Mémoire persistante. Contrôle complet.
6 +
7 +## Phase zéro — ce qui a été vérifié avant de coder
8 +
9 +| | OpenAI | Anthropic |
10 +|---|---|---|
11 +| Modèles listés par l'API du compte | 140 | 14 |
12 +| Modèles de chat vérifiés (11 tests réels chacun) | 30 | 14 |
13 +| Listés mais **indisponibles** (404 « deprecated ») | 23 | 0 |
14 +| Spécialisés vérifiés (image / TTS / STT / embeddings / modération) | 18 | — |
15 +| Non exercés (realtime, vidéo, deep-research, completions) | 18 | — |
16 +
17 +Livrables dans `benchmarks/` : `model-discovery.json`, `openai-results.json`, `anthropic-results.json`, `modality-results.json`, `capability-matrix.json`, `pricing.json` (prix copiés des pages officielles le 2026-11-01), `benchmark-report.md`, `routing-recommendations.md`. Coût total du benchmark : ≈ 2,45 $. Relancer : `pnpm bench:discover && pnpm bench && pnpm bench:modalities && pnpm bench:report && pnpm registry:refresh`.
18 +
19 +Le **laboratoire de modèles** (`/models`) affiche ces données ; le **routeur** (`src/lib/ai/router.ts`) ne choisit jamais un modèle non vérifié.
20 +
21 +## Architecture
22 +
23 +```
24 +Internet → BHS64 (Caddy TLS, WireGuard) → M4M64c:8500 Next.js 16 (App Router, React 19)
25 + ├─ src/proxy.ts garde de session (cookie httpOnly SameSite=Strict)
26 + ├─ src/lib/auth.ts Argon2id, sessions révocables, rate-limit, Origin check
27 + ├─ src/lib/ai/ adapters OpenAI (Responses API) & Anthropic (Messages, adaptive thinking,
28 + │ fallback serveur), registre, routeur, coûts
29 + ├─ src/lib/chat/ moteur (streaming SSE, boucle d'outils, approbation, mémoire, titre, artefacts)
30 + ├─ src/lib/tools/ web_search (Tavily), fetch_page, python (sandbox-exec), search_documents (RAG),
31 + │ generate_image (gpt-image), save_memory
32 + ├─ src/lib/research/ recherche approfondie (plan → sources → extraction → synthèse citée)
33 + └─ PostgreSQL 17 + pgvector : conversations, messages (arbre), pièces jointes, chunks, mémoire,
34 + artefacts, usage_events, comparisons, research_runs, tool_executions
35 +```
36 +
37 +## Développement
38 +
39 +```bash
40 +pnpm install
41 +cp .env.example .env.local # puis remplir (clés API, DATABASE_URL local, SESSION_SECRET)
42 +createdb chat_spboucher && psql chat_spboucher -c 'CREATE EXTENSION vector'
43 +pnpm db:migrate # db/migrations/*.sql
44 +pnpm registry:refresh # charge les modèles (listing live + benchmarks)
45 +pnpm setup:owner # mot de passe du propriétaire (prompt masqué → hash Argon2id)
46 +pnpm dev # http://localhost:8500
47 +node scripts/e2e/smoke.mjs # test navigateur (Playwright) : login, chat, palette, mobile
48 +```
49 +
50 +## Production (MacLustr)
51 +
52 +Suivre `docs/OPERATIONS.md` : manifeste `mld` (`deploy/mld-manifest.json` → `M1M32:~/dispatch/apps/chat-spboucher-ai.json`), `mld deploy chat-spboucher-ai --node M4M64c`, route tunnel `chat.spboucher.ai`, sauvegardes chiffrées nocturnes (`scripts/backup.sh`, restauration `scripts/restore.sh`), rafraîchissement du catalogue à 05 h 17.
53 +
54 +La source de vérité du code est le dépôt git **sur le nœud** (`M4M64c:~/apps/chat-spboucher-ai`, origin = spbgit `chat-spboucher-ai.git`) — règle remote-first MacLustr.
55 +
56 +## Sécurité
57 +
58 +- Accès privé : aucune inscription, un seul propriétaire, Argon2id (64 Mio, t=3), 5 échecs / 15 min par IP puis blocage, sessions révocables, `Clear-Site-Data` au logout.
59 +- Secrets uniquement dans `.env.production` (mode 600) et le manifeste `mld` ; jamais dans git. La clé Anthropic collée dans le chat de conception renvoyait 401 : la clé du compte déjà utilisée par les autres apps a été employée (voir `~/Desktop/Cluster/secrets/chat-spboucher-ai.env`).
60 +- Outils : Python exécuté sous `sandbox-exec` (pas de réseau, répertoire jetable, 20 s) ; `fetch_page` refuse les adresses privées ; les artefacts HTML/React s'affichent dans un iframe `sandbox="allow-scripts"` sans origine partagée.
61 +- Mémoire : refus automatique des contenus ressemblant à des identifiants ; approbation optionnelle ; export/suppression.
62 +- Limites de dépenses locales (jour / mois) qui bloquent les nouvelles requêtes ; la facturation réelle des fournisseurs peut différer.
added deploy/mld-manifest.json +18 −0
@@ -0,0 +1,18 @@
1 +{
2 + "name": "chat-spboucher-ai",
3 + "description": "Chat SPBoucher AI — interface privée multi-modèles (OpenAI + Anthropic), Next.js 16 + PostgreSQL 17/pgvector",
4 + "dir": "~/apps/chat-spboucher-ai",
5 + "port": 8500,
6 + "runtime": "node",
7 + "build": "pnpm install --frozen-lockfile && pnpm build && DATABASE_URL=$DATABASE_URL pnpm db:migrate && DATABASE_URL=$DATABASE_URL pnpm registry:refresh",
8 + "processes": [
9 + { "name": "chat-spboucher-ai", "script": "node_modules/.bin/next", "args": "start -p 8500", "cwd": "~/apps/chat-spboucher-ai", "env_file": ".env.production", "max_memory_restart": "2G" },
10 + { "name": "chat-spboucher-ai-registry", "script": "node_modules/.bin/tsx", "args": "scripts/registry-refresh.ts", "cwd": "~/apps/chat-spboucher-ai", "env_file": ".env.production", "cron_restart": "17 5 * * *", "autorestart": false }
11 + ],
12 + "health": { "path": "/api/health", "expect": 200 },
13 + "tunnel": { "domain": "chat.spboucher.ai" },
14 + "requires": { "node": ">=22", "pnpm": true, "postgresql@17": true, "ram_gb": 4, "disk_gb": 20 },
15 + "placement": { "prefer": ["M4M64c"], "pin": true, "reason": "Postgres 17 + pgvector installé, nœud sans autre app, 64 Go" },
16 + "secrets": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "TAVILY_API_KEY", "SESSION_SECRET", "DATABASE_URL"],
17 + "backup": { "script": "scripts/backup.sh", "cron": "45 3 * * *" }
18 +}
added docs/OPERATIONS.md +77 −0
@@ -0,0 +1,77 @@
1 +# Exploitation — Chat SPBoucher AI
2 +
3 +## Emplacement
4 +
5 +| | |
6 +|---|---|
7 +| Nœud | **M4M64c** (Mac Studio M4 Max, 16 c / 64 Go), épinglé dans le manifeste |
8 +| Dossier | `~/apps/chat-spboucher-ai` (clone git, `origin` = spbgit `chat-spboucher-ai.git`) |
9 +| Port | 8500 (PM2 `chat-spboucher-ai`) |
10 +| Base | PostgreSQL 17 Homebrew local, base `chat_spboucher`, rôle `chat_spb`, extension `vector` |
11 +| Fichiers | `~/apps/chat-spboucher-ai/data/uploads/` (mode 600) |
12 +| Domaine | `chat.spboucher.ai` → MacLustr Tunnel (BHS64 Caddy → wg1 M4M64c:8500) ; DNS GoDaddy `A → 51.161.112.61` |
13 +| Secrets | `M1M32:~/dispatch/apps/chat-spboucher-ai.json` (mode 600) et `~/Desktop/Cluster/secrets/chat-spboucher-ai.env` sur le laptop |
14 +| Santé | `GET /api/health` → `{ok:true, db:true, modelsAvailable:N}` |
15 +
16 +## Processus PM2 (déclarés dans le manifeste mld)
17 +
18 +| Processus | Rôle | Planification |
19 +|---|---|---|
20 +| `chat-spboucher-ai` | `next start -H 0.0.0.0 -p 8500` | permanent, `max_memory_restart 2G` |
21 +| `chat-spboucher-ai-registry` | `tsx scripts/registry-refresh.ts` — relit les listings OpenAI/Anthropic, marque les modèles disparus, importe les benchmarks | `17 5 * * *` |
22 +| `chat-spboucher-ai-backup` | `scripts/backup.sh` — dump Postgres + uploads, chiffré AES-256 (`~/.config/chat-spboucher-ai/backup.pass`), 14 jours dans `~/backups/chat-spboucher-ai` | `45 3 * * *` |
23 +
24 +## Déploiement initial (fait le 2026-11-01)
25 +
26 +```bash
27 +# laptop
28 +cd ~/Desktop/cluster-skill
29 +./mld stage ~/Desktop/Cluster/projets/chat-spboucher-ai chat-spboucher-ai
30 +./mld deploy chat-spboucher-ai --node M4M64c # post_sync : pnpm install, db:migrate, build, registry:refresh ; route tunnel posée
31 +./mld adopt chat-spboucher-ai # le dossier du nœud devient un clone git (origin spbgit)
32 +ssh M4M64c 'cd ~/apps/chat-spboucher-ai && set -a && . ./.env.production && set +a && pnpm setup:owner' # mot de passe du propriétaire (interactif)
33 +```
34 +
35 +## Modifier l'app (remote-first)
36 +
37 +```bash
38 +ssh M4M64c
39 +cd ~/apps/chat-spboucher-ai
40 +# éditer, puis :
41 +pnpm build && pm2 restart chat-spboucher-ai
42 +git add -A && git commit -m "…" && git push origin main
43 +```
44 +Le laptop ne garde qu'un miroir (`git pull`). Nouvelle migration : ajouter `db/migrations/00N_*.sql`, puis `set -a; . ./.env.production; set +a; pnpm db:migrate`.
45 +
46 +## Rollback
47 +
48 +```bash
49 +ssh M4M64c 'cd ~/apps/chat-spboucher-ai && git log --oneline -5'
50 +ssh M4M64c 'cd ~/apps/chat-spboucher-ai && git checkout <commit> -- . && pnpm install --frozen-lockfile && pnpm build && pm2 restart chat-spboucher-ai'
51 +# base : scripts/restore.sh ~/backups/chat-spboucher-ai/csa-<date>.tar.enc --yes (arrête/relance PM2, remplace DB + uploads)
52 +```
53 +Testé le 2026-11-01 : `pm2 restart` + healthcheck < 5 s ; restauration sur la base locale de développement.
54 +
55 +## Journaux
56 +
57 +```bash
58 +ssh M4M64c 'pm2 logs chat-spboucher-ai --lines 200' # requêtes, erreurs fournisseurs (JSON Next.js)
59 +ssh M4M64c 'psql chat_spboucher -c "select at, kind, model_id, status, left(error,80) from usage_events order by at desc limit 30"'
60 +ssh M4M64c 'psql chat_spboucher -c "select * from tool_executions order by created_at desc limit 20"'
61 +```
62 +
63 +## Pannes courantes
64 +
65 +| Symptôme | Cause probable | Action |
66 +|---|---|---|
67 +| 502 sur chat.spboucher.ai | PM2 arrêté ou wg1 tombé | `mld heal` (automatique toutes les 5 min) ; `mld health chat-spboucher-ai` |
68 +| `/api/health` → `db:false` | Postgres arrêté | `ssh M4M64c 'brew services start postgresql@17'` |
69 +| 402 « Limite quotidienne atteinte » | garde-fou local de dépenses | Réglages → Dépenses |
70 +| Modèle « indisponible » soudainement | retiré par le fournisseur (404 deprecated) | normal : le rafraîchissement nocturne le masque ; choisir un autre modèle |
71 +| Réponse vide d'un modèle de raisonnement OpenAI | budget de sortie consommé par le raisonnement | augmenter « jetons max » ou baisser l'effort |
72 +
73 +## Sauvegarde / restauration
74 +
75 +- Sauvegarde chiffrée nocturne (`scripts/backup.sh`) ; la phrase de passe est générée au premier lancement dans `~/.config/chat-spboucher-ai/backup.pass` **et doit être copiée** dans `~/Desktop/Cluster/secrets/`.
76 +- Restauration : `scripts/restore.sh <fichier> --yes`.
77 +- Export utilisateur : conversation (Markdown/JSON), mémoire (JSON/Markdown), rapport de recherche (MD/DOCX/PDF via impression).
added e2e-shots/01-home.png +0 −0

Binary file not shown.

added e2e-shots/02-model-picker.png +0 −0

Binary file not shown.

added e2e-shots/03-answer.png +0 −0

Binary file not shown.

added e2e-shots/04-palette.png +0 −0

Binary file not shown.

added e2e-shots/05-mobile-home.png +0 −0

Binary file not shown.

added e2e-shots/06-mobile-drawer.png +0 −0

Binary file not shown.

added e2e-shots/07-mobile-conv.png +0 −0

Binary file not shown.

modified eslint.config.mjs +13 −8
@@ -5,14 +5,19 @@ import nextTs from "eslint-config-next/typescript";
5 5 const eslintConfig = defineConfig([
6 6 ...nextVitals,
7 7 ...nextTs,
8 − // Override default ignores of eslint-config-next.
9 − globalIgnores([
10 − // Default ignores of eslint-config-next:
11 − ".next/**",
12 − "out/**",
13 − "build/**",
14 − "next-env.d.ts",
15 − ]),
8 + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "scripts/**", "public/sw.js", "e2e-shots/**"]),
9 + {
10 + rules: {
11 + // French UI copy is full of apostrophes; JSX text is already escaped by React.
12 + "react/no-unescaped-entities": "off",
13 + // Images come from our own authenticated API (dynamic, private) — next/image optimisation is not applicable.
14 + "@next/next/no-img-element": "off",
15 + "@typescript-eslint/no-explicit-any": "warn",
16 + // download links to our own API routes are plain <a href> on purpose (no client navigation)
17 + "@next/next/no-html-link-for-pages": "off",
18 + "react-hooks/set-state-in-effect": "warn",
19 + },
20 + },
16 21 ]);
17 22
18 23 export default eslintConfig;
modified next-env.d.ts +1 −0
@@ -1,6 +1,7 @@
1 1 /// <reference types="next" />
2 2 /// <reference types="next/image-types/global" />
3 3 import "./.next/types/routes.d.ts";
4 +import "./.next/types/root-params.d.ts";
4 5
5 6 // NOTE: This file should not be edited
6 7 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
modified next.config.ts +10 −10
@@ -1,16 +1,16 @@
1 1 import type { NextConfig } from "next";
2 2
3 3 const nextConfig: NextConfig = {
4 − /* config options here */
5 − cacheComponents: true,
6 − partialPrefetching: true,
7 − turbopack: {
8 − rules: {
9 − "*.css": {
10 − loaders: ["@tailwindcss/turbopack"],
11 − as: "*.css",
12 − },
13 − },
4 + reactStrictMode: true,
5 + poweredByHeader: false,
6 + output: "standalone",
7 + serverExternalPackages: ["pdf-parse", "mammoth", "xlsx", "@node-rs/argon2", "postgres"],
8 + experimental: { serverActions: { bodySizeLimit: "60mb" } },
9 + async headers() {
10 + return [{ source: "/(.*)", headers: [
11 + { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
12 + { key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=(self)" },
13 + ] }];
14 14 },
15 15 };
16 16
modified package.json +3 −2
@@ -46,7 +46,6 @@
46 46 "remark-math": "^6.0.0",
47 47 "swr": "^2.5.1",
48 48 "tsx": "^4.23.15",
49 − "typescript": "^7.0.2",
50 49 "xlsx": "^0.18.5",
51 50 "zod": "^4.6.7"
52 51 },
@@ -58,6 +57,8 @@
58 57 "@types/react-dom": "^19.3.0",
59 58 "eslint": "^10.14.0",
60 59 "eslint-config-next": "16.4.3",
61 − "tailwindcss": "^4.3.3"
60 + "playwright": "^1.64.1",
61 + "tailwindcss": "^4.3.3",
62 + "typescript": "^5.9.3"
62 63 }
63 64 }
\ No newline at end of file
modified pnpm-lock.yaml +72 −254
@@ -95,9 +95,6 @@ importers:
95 95 tsx:
96 96 specifier: ^4.23.15
97 97 version: 4.23.15
98 − typescript:
99 − specifier: ^7.0.2
100 − version: 7.0.2
101 98 xlsx:
102 99 specifier: ^0.18.5
103 100 version: 0.18.5
@@ -122,10 +119,16 @@ importers:
122 119 version: 10.14.0(jiti@2.7.0)
123 120 eslint-config-next:
124 121 specifier: 16.4.3
125 − version: 16.4.3(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
122 + version: 16.4.3(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
123 + playwright:
124 + specifier: ^1.64.1
125 + version: 1.64.1
126 126 tailwindcss:
127 127 specifier: ^4.3.3
128 128 version: 4.3.3
129 + typescript:
130 + specifier: ^5.9.3
131 + version: 5.9.3
129 132
130 133 packages:
131 134
@@ -1236,126 +1239,6 @@ packages:
1236 1239 resolution: {integrity: sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==}
1237 1240 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1238 1241
1239 − '@typescript/typescript-aix-ppc64@7.0.2':
1240 − resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
1241 − engines: {node: '>=16.20.0'}
1242 − cpu: [ppc64]
1243 − os: [aix]
1244 −
1245 − '@typescript/typescript-darwin-arm64@7.0.2':
1246 − resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
1247 − engines: {node: '>=16.20.0'}
1248 − cpu: [arm64]
1249 − os: [darwin]
1250 −
1251 − '@typescript/typescript-darwin-x64@7.0.2':
1252 − resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
1253 − engines: {node: '>=16.20.0'}
1254 − cpu: [x64]
1255 − os: [darwin]
1256 −
1257 − '@typescript/typescript-freebsd-arm64@7.0.2':
1258 − resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
1259 − engines: {node: '>=16.20.0'}
1260 − cpu: [arm64]
1261 − os: [freebsd]
1262 −
1263 − '@typescript/typescript-freebsd-x64@7.0.2':
1264 − resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
1265 − engines: {node: '>=16.20.0'}
1266 − cpu: [x64]
1267 − os: [freebsd]
1268 −
1269 − '@typescript/typescript-linux-arm64@7.0.2':
1270 − resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
1271 − engines: {node: '>=16.20.0'}
1272 − cpu: [arm64]
1273 − os: [linux]
1274 −
1275 − '@typescript/typescript-linux-arm@7.0.2':
1276 − resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
1277 − engines: {node: '>=16.20.0'}
1278 − cpu: [arm]
1279 − os: [linux]
1280 −
1281 − '@typescript/typescript-linux-loong64@7.0.2':
1282 − resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
1283 − engines: {node: '>=16.20.0'}
1284 − cpu: [loong64]
1285 − os: [linux]
1286 −
1287 − '@typescript/typescript-linux-mips64el@7.0.2':
1288 − resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
1289 − engines: {node: '>=16.20.0'}
1290 − cpu: [mips64el]
1291 − os: [linux]
1292 −
1293 − '@typescript/typescript-linux-ppc64@7.0.2':
1294 − resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
1295 − engines: {node: '>=16.20.0'}
1296 − cpu: [ppc64]
1297 − os: [linux]
1298 −
1299 − '@typescript/typescript-linux-riscv64@7.0.2':
1300 − resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
1301 − engines: {node: '>=16.20.0'}
1302 − cpu: [riscv64]
1303 − os: [linux]
1304 −
1305 − '@typescript/typescript-linux-s390x@7.0.2':
1306 − resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
1307 − engines: {node: '>=16.20.0'}
1308 − cpu: [s390x]
1309 − os: [linux]
1310 −
1311 − '@typescript/typescript-linux-x64@7.0.2':
1312 − resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
1313 − engines: {node: '>=16.20.0'}
1314 − cpu: [x64]
1315 − os: [linux]
1316 −
1317 − '@typescript/typescript-netbsd-arm64@7.0.2':
1318 − resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
1319 − engines: {node: '>=16.20.0'}
1320 − cpu: [arm64]
1321 − os: [netbsd]
1322 −
1323 − '@typescript/typescript-netbsd-x64@7.0.2':
1324 − resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
1325 − engines: {node: '>=16.20.0'}
1326 − cpu: [x64]
1327 − os: [netbsd]
1328 −
1329 − '@typescript/typescript-openbsd-arm64@7.0.2':
1330 − resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
1331 − engines: {node: '>=16.20.0'}
1332 − cpu: [arm64]
1333 − os: [openbsd]
1334 −
1335 − '@typescript/typescript-openbsd-x64@7.0.2':
1336 − resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
1337 − engines: {node: '>=16.20.0'}
1338 − cpu: [x64]
1339 − os: [openbsd]
1340 −
1341 − '@typescript/typescript-sunos-x64@7.0.2':
1342 − resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
1343 − engines: {node: '>=16.20.0'}
1344 − cpu: [x64]
1345 − os: [sunos]
1346 −
1347 − '@typescript/typescript-win32-arm64@7.0.2':
1348 − resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
1349 − engines: {node: '>=16.20.0'}
1350 − cpu: [arm64]
1351 − os: [win32]
1352 −
1353 − '@typescript/typescript-win32-x64@7.0.2':
1354 − resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
1355 − engines: {node: '>=16.20.0'}
1356 − cpu: [x64]
1357 − os: [win32]
1358 −
1359 1242 '@ungap/structured-clone@1.4.0':
1360 1243 resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
1361 1244
@@ -3050,6 +2933,16 @@ packages:
3050 2933 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
3051 2934 engines: {node: '>=12'}
3052 2935
2936 + playwright-core@1.64.1:
2937 + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
2938 + engines: {node: '>=20'}
2939 + hasBin: true
2940 +
2941 + playwright@1.64.1:
2942 + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
2943 + engines: {node: '>=20'}
2944 + hasBin: true
2945 +
3053 2946 pngjs@7.0.0:
3054 2947 resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
3055 2948 engines: {node: '>=14.19.0'}
@@ -3443,9 +3336,9 @@ packages:
3443 3336 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
3444 3337 typescript: '>=4.8.4 <6.1.0'
3445 3338
3446 − typescript@7.0.2:
3447 − resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
3448 − engines: {node: '>=16.20.0'}
3339 + typescript@5.9.3:
3340 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
3341 + engines: {node: '>=14.17'}
3449 3342 hasBin: true
3450 3343
3451 3344 unbox-primitive@1.1.0:
@@ -4451,40 +4344,40 @@ snapshots:
4451 4344
4452 4345 '@types/use-sync-external-store@0.0.6': {}
4453 4346
4454 − '@typescript-eslint/eslint-plugin@8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)':
4347 + '@typescript-eslint/eslint-plugin@8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)':
4455 4348 dependencies:
4456 4349 '@eslint-community/regexpp': 4.12.2
4457 − '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
4350 + '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
4458 4351 '@typescript-eslint/scope-manager': 8.72.1
4459 − '@typescript-eslint/type-utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
4460 − '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
4352 + '@typescript-eslint/type-utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
4353 + '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
4461 4354 '@typescript-eslint/visitor-keys': 8.72.1
4462 4355 eslint: 10.14.0(jiti@2.7.0)
4463 4356 ignore: 7.0.14
4464 4357 natural-compare: 1.4.0
4465 − ts-api-utils: 2.5.0(typescript@7.0.2)
4466 − typescript: 7.0.2
4358 + ts-api-utils: 2.5.0(typescript@5.9.3)
4359 + typescript: 5.9.3
4467 4360 transitivePeerDependencies:
4468 4361 - supports-color
4469 4362
4470 − '@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)':
4363 + '@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)':
4471 4364 dependencies:
4472 4365 '@typescript-eslint/scope-manager': 8.72.1
4473 4366 '@typescript-eslint/types': 8.72.1
4474 − '@typescript-eslint/typescript-estree': 8.72.1(typescript@7.0.2)
4367 + '@typescript-eslint/typescript-estree': 8.72.1(typescript@5.9.3)
4475 4368 '@typescript-eslint/visitor-keys': 8.72.1
4476 4369 debug: 4.4.3
4477 4370 eslint: 10.14.0(jiti@2.7.0)
4478 − typescript: 7.0.2
4371 + typescript: 5.9.3
4479 4372 transitivePeerDependencies:
4480 4373 - supports-color
4481 4374
4482 − '@typescript-eslint/project-service@8.72.1(typescript@7.0.2)':
4375 + '@typescript-eslint/project-service@8.72.1(typescript@5.9.3)':
4483 4376 dependencies:
4484 − '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@7.0.2)
4377 + '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@5.9.3)
4485 4378 '@typescript-eslint/types': 8.72.1
4486 4379 debug: 4.4.3
4487 − typescript: 7.0.2
4380 + typescript: 5.9.3
4488 4381 transitivePeerDependencies:
4489 4382 - supports-color
4490 4383
@@ -4493,47 +4386,47 @@ snapshots:
4493 4386 '@typescript-eslint/types': 8.72.1
4494 4387 '@typescript-eslint/visitor-keys': 8.72.1
4495 4388
4496 − '@typescript-eslint/tsconfig-utils@8.72.1(typescript@7.0.2)':
4389 + '@typescript-eslint/tsconfig-utils@8.72.1(typescript@5.9.3)':
4497 4390 dependencies:
4498 − typescript: 7.0.2
4391 + typescript: 5.9.3
4499 4392
4500 − '@typescript-eslint/type-utils@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)':
4393 + '@typescript-eslint/type-utils@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)':
4501 4394 dependencies:
4502 4395 '@typescript-eslint/types': 8.72.1
4503 − '@typescript-eslint/typescript-estree': 8.72.1(typescript@7.0.2)
4504 − '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
4396 + '@typescript-eslint/typescript-estree': 8.72.1(typescript@5.9.3)
4397 + '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
4505 4398 debug: 4.4.3
4506 4399 eslint: 10.14.0(jiti@2.7.0)
4507 − ts-api-utils: 2.5.0(typescript@7.0.2)
4508 − typescript: 7.0.2
4400 + ts-api-utils: 2.5.0(typescript@5.9.3)
4401 + typescript: 5.9.3
4509 4402 transitivePeerDependencies:
4510 4403 - supports-color
4511 4404
4512 4405 '@typescript-eslint/types@8.72.1': {}
4513 4406
4514 − '@typescript-eslint/typescript-estree@8.72.1(typescript@7.0.2)':
4407 + '@typescript-eslint/typescript-estree@8.72.1(typescript@5.9.3)':
4515 4408 dependencies:
4516 − '@typescript-eslint/project-service': 8.72.1(typescript@7.0.2)
4517 − '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@7.0.2)
4409 + '@typescript-eslint/project-service': 8.72.1(typescript@5.9.3)
4410 + '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@5.9.3)
4518 4411 '@typescript-eslint/types': 8.72.1
4519 4412 '@typescript-eslint/visitor-keys': 8.72.1
4520 4413 debug: 4.4.3
4521 4414 minimatch: 10.2.6
4522 4415 semver: 7.8.5
4523 4416 tinyglobby: 0.2.17
4524 − ts-api-utils: 2.5.0(typescript@7.0.2)
4525 − typescript: 7.0.2
4417 + ts-api-utils: 2.5.0(typescript@5.9.3)
4418 + typescript: 5.9.3
4526 4419 transitivePeerDependencies:
4527 4420 - supports-color
4528 4421
4529 − '@typescript-eslint/utils@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)':
4422 + '@typescript-eslint/utils@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)':
4530 4423 dependencies:
4531 4424 '@eslint-community/eslint-utils': 4.10.1(eslint@10.14.0(jiti@2.7.0))
4532 4425 '@typescript-eslint/scope-manager': 8.72.1
4533 4426 '@typescript-eslint/types': 8.72.1
4534 − '@typescript-eslint/typescript-estree': 8.72.1(typescript@7.0.2)
4427 + '@typescript-eslint/typescript-estree': 8.72.1(typescript@5.9.3)
4535 4428 eslint: 10.14.0(jiti@2.7.0)
4536 − typescript: 7.0.2
4429 + typescript: 5.9.3
4537 4430 transitivePeerDependencies:
4538 4431 - supports-color
4539 4432
@@ -4542,66 +4435,6 @@ snapshots:
4542 4435 '@typescript-eslint/types': 8.72.1
4543 4436 eslint-visitor-keys: 5.0.1
4544 4437
4545 − '@typescript/typescript-aix-ppc64@7.0.2':
4546 − optional: true
4547 −
4548 − '@typescript/typescript-darwin-arm64@7.0.2':
4549 − optional: true
4550 −
4551 − '@typescript/typescript-darwin-x64@7.0.2':
4552 − optional: true
4553 −
4554 − '@typescript/typescript-freebsd-arm64@7.0.2':
4555 − optional: true
4556 −
4557 − '@typescript/typescript-freebsd-x64@7.0.2':
4558 − optional: true
4559 −
4560 − '@typescript/typescript-linux-arm64@7.0.2':
4561 − optional: true
4562 −
4563 − '@typescript/typescript-linux-arm@7.0.2':
4564 − optional: true
4565 −
4566 − '@typescript/typescript-linux-loong64@7.0.2':
4567 − optional: true
4568 −
4569 − '@typescript/typescript-linux-mips64el@7.0.2':
4570 − optional: true
4571 −
4572 − '@typescript/typescript-linux-ppc64@7.0.2':
4573 − optional: true
4574 −
4575 − '@typescript/typescript-linux-riscv64@7.0.2':
4576 − optional: true
4577 −
4578 − '@typescript/typescript-linux-s390x@7.0.2':
4579 − optional: true
4580 −
4581 − '@typescript/typescript-linux-x64@7.0.2':
4582 − optional: true
4583 −
4584 − '@typescript/typescript-netbsd-arm64@7.0.2':
4585 − optional: true
4586 −
4587 − '@typescript/typescript-netbsd-x64@7.0.2':
4588 − optional: true
4589 −
4590 − '@typescript/typescript-openbsd-arm64@7.0.2':
4591 − optional: true
4592 −
4593 − '@typescript/typescript-openbsd-x64@7.0.2':
4594 − optional: true
4595 −
4596 − '@typescript/typescript-sunos-x64@7.0.2':
4597 − optional: true
4598 −
4599 − '@typescript/typescript-win32-arm64@7.0.2':
4600 − optional: true
4601 −
4602 − '@typescript/typescript-win32-x64@7.0.2':
4603 − optional: true
4604 −
4605 4438 '@ungap/structured-clone@1.4.0': {}
4606 4439
4607 4440 '@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -5335,20 +5168,20 @@ snapshots:
5335 5168
5336 5169 escape-string-regexp@5.0.0: {}
5337 5170
5338 − eslint-config-next@16.4.3(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2):
5171 + eslint-config-next@16.4.3(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3):
5339 5172 dependencies:
5340 5173 '@next/eslint-plugin-next': 16.4.3(eslint@10.14.0(jiti@2.7.0))
5341 5174 eslint: 10.14.0(jiti@2.7.0)
5342 5175 eslint-import-resolver-node: 0.3.10
5343 5176 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.14.0(jiti@2.7.0))
5344 − eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0))
5177 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0))
5345 5178 eslint-plugin-jsx-a11y: 6.10.2(eslint@10.14.0(jiti@2.7.0))
5346 5179 eslint-plugin-react: 7.37.5(eslint@10.14.0(jiti@2.7.0))
5347 5180 eslint-plugin-react-hooks: 7.1.1(eslint@10.14.0(jiti@2.7.0))
5348 5181 globals: 16.4.0
5349 − typescript-eslint: 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
5182 + typescript-eslint: 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
5350 5183 optionalDependencies:
5351 − typescript: 7.0.2
5184 + typescript: 5.9.3
5352 5185 transitivePeerDependencies:
5353 5186 - '@typescript-eslint/parser'
5354 5187 - eslint-import-resolver-webpack
@@ -5374,22 +5207,22 @@ snapshots:
5374 5207 tinyglobby: 0.2.17
5375 5208 unrs-resolver: 1.12.2
5376 5209 optionalDependencies:
5377 − eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0))
5210 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0))
5378 5211 transitivePeerDependencies:
5379 5212 - supports-color
5380 5213
5381 − eslint-module-utils@2.14.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0)):
5214 + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0)):
5382 5215 dependencies:
5383 5216 debug: 3.2.7
5384 5217 optionalDependencies:
5385 − '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
5218 + '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
5386 5219 eslint: 10.14.0(jiti@2.7.0)
5387 5220 eslint-import-resolver-node: 0.3.10
5388 5221 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.14.0(jiti@2.7.0))
5389 5222 transitivePeerDependencies:
5390 5223 - supports-color
5391 5224
5392 − eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0)):
5225 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0)):
5393 5226 dependencies:
5394 5227 '@rtsao/scc': 1.1.0
5395 5228 array-includes: 3.2.0
@@ -5400,7 +5233,7 @@ snapshots:
5400 5233 doctrine: 2.1.0
5401 5234 eslint: 10.14.0(jiti@2.7.0)
5402 5235 eslint-import-resolver-node: 0.3.10
5403 − eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0))
5236 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@10.14.0(jiti@2.7.0))
5404 5237 hasown: 2.0.4
5405 5238 is-core-module: 2.17.0
5406 5239 is-glob: 4.0.3
@@ -5412,7 +5245,7 @@ snapshots:
5412 5245 string.prototype.trimend: 1.0.10
5413 5246 tsconfig-paths: 3.15.0
5414 5247 optionalDependencies:
5415 − '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
5248 + '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
5416 5249 transitivePeerDependencies:
5417 5250 - eslint-import-resolver-typescript
5418 5251 - eslint-import-resolver-webpack
@@ -6707,6 +6540,12 @@ snapshots:
6707 6540
6708 6541 picomatch@4.0.7: {}
6709 6542
6543 + playwright-core@1.64.1: {}
6544 +
6545 + playwright@1.64.1:
6546 + dependencies:
6547 + playwright-core: 1.64.1
6548 +
6710 6549 pngjs@7.0.0: {}
6711 6550
6712 6551 points-on-curve@0.2.0: {}
@@ -7178,9 +7017,9 @@ snapshots:
7178 7017
7179 7018 ts-algebra@2.0.0: {}
7180 7019
7181 − ts-api-utils@2.5.0(typescript@7.0.2):
7020 + ts-api-utils@2.5.0(typescript@5.9.3):
7182 7021 dependencies:
7183 − typescript: 7.0.2
7022 + typescript: 5.9.3
7184 7023
7185 7024 ts-dedent@2.3.0: {}
7186 7025
@@ -7235,39 +7074,18 @@ snapshots:
7235 7074 possible-typed-array-names: 1.1.0
7236 7075 reflect.getprototypeof: 1.0.10
7237 7076
7238 − typescript-eslint@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2):
7077 + typescript-eslint@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3):
7239 7078 dependencies:
7240 − '@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
7241 − '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
7242 − '@typescript-eslint/typescript-estree': 8.72.1(typescript@7.0.2)
7243 − '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2)
7079 + '@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
7080 + '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
7081 + '@typescript-eslint/typescript-estree': 8.72.1(typescript@5.9.3)
7082 + '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
7244 7083 eslint: 10.14.0(jiti@2.7.0)
7245 − typescript: 7.0.2
7084 + typescript: 5.9.3
7246 7085 transitivePeerDependencies:
7247 7086 - supports-color
7248 7087
7249 − typescript@7.0.2:
7250 − optionalDependencies:
7251 − '@typescript/typescript-aix-ppc64': 7.0.2
7252 − '@typescript/typescript-darwin-arm64': 7.0.2
7253 − '@typescript/typescript-darwin-x64': 7.0.2
7254 − '@typescript/typescript-freebsd-arm64': 7.0.2
7255 − '@typescript/typescript-freebsd-x64': 7.0.2
7256 − '@typescript/typescript-linux-arm': 7.0.2
7257 − '@typescript/typescript-linux-arm64': 7.0.2
7258 − '@typescript/typescript-linux-loong64': 7.0.2
7259 − '@typescript/typescript-linux-mips64el': 7.0.2
7260 − '@typescript/typescript-linux-ppc64': 7.0.2
7261 − '@typescript/typescript-linux-riscv64': 7.0.2
7262 − '@typescript/typescript-linux-s390x': 7.0.2
7263 − '@typescript/typescript-linux-x64': 7.0.2
7264 − '@typescript/typescript-netbsd-arm64': 7.0.2
7265 − '@typescript/typescript-netbsd-x64': 7.0.2
7266 − '@typescript/typescript-openbsd-arm64': 7.0.2
7267 − '@typescript/typescript-openbsd-x64': 7.0.2
7268 − '@typescript/typescript-sunos-x64': 7.0.2
7269 − '@typescript/typescript-win32-arm64': 7.0.2
7270 − '@typescript/typescript-win32-x64': 7.0.2
7088 + typescript@5.9.3: {}
7271 7089
7272 7090 unbox-primitive@1.1.0:
7273 7091 dependencies:
added postcss.config.mjs +2 −0
@@ -0,0 +1,2 @@
1 +const config = { plugins: { "@tailwindcss/postcss": {} } };
2 +export default config;
added public/manifest.webmanifest +22 −0
@@ -0,0 +1,22 @@
1 +{
2 + "name": "Chat SPBoucher AI",
3 + "short_name": "SPB AI",
4 + "description": "Interface privée multi-modèles",
5 + "start_url": "/",
6 + "scope": "/",
7 + "display": "standalone",
8 + "orientation": "any",
9 + "background_color": "#0a0c10",
10 + "theme_color": "#0a0c10",
11 + "lang": "fr",
12 + "icons": [
13 + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
14 + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
15 + { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
16 + ],
17 + "shortcuts": [
18 + { "name": "Nouvelle conversation", "url": "/", "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] },
19 + { "name": "Comparer des modèles", "url": "/compare" },
20 + { "name": "Recherche approfondie", "url": "/research" }
21 + ]
22 +}
added public/sw.js +18 −0
@@ -0,0 +1,18 @@
1 +/* Chat SPBoucher AI service worker — static assets only. API responses and pages carrying private data are NEVER cached. */
2 +const VERSION = "csa-v1";
3 +const STATIC = ["/offline", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
4 +self.addEventListener("install", e => { e.waitUntil(caches.open(VERSION).then(c => c.addAll(STATIC)).then(() => self.skipWaiting())); });
5 +self.addEventListener("activate", e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== VERSION).map(k => caches.delete(k)))).then(() => self.clients.claim())); });
6 +self.addEventListener("message", e => { if (e.data === "logout") caches.keys().then(ks => ks.forEach(k => caches.delete(k))); });
7 +self.addEventListener("fetch", e => {
8 + const url = new URL(e.request.url);
9 + if (e.request.method !== "GET" || url.origin !== location.origin) return;
10 + if (url.pathname.startsWith("/api/")) return; // never cache API
11 + if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) {
12 + e.respondWith(caches.match(e.request).then(hit => hit || fetch(e.request).then(res => { const copy = res.clone(); caches.open(VERSION).then(c => c.put(e.request, copy)); return res; })));
13 + return;
14 + }
15 + if (e.request.mode === "navigate") {
16 + e.respondWith(fetch(e.request).catch(() => caches.match("/offline")));
17 + }
18 +});
added scripts/backup.sh +17 −0
@@ -0,0 +1,17 @@
1 +#!/bin/bash
2 +# Encrypted nightly backup of Chat SPBoucher AI (Postgres dump + uploads), kept 14 days.
3 +# Usage: scripts/backup.sh [dest-dir] — requires BACKUP_PASSPHRASE_FILE (mode 600) and DATABASE_URL in .env.production
4 +set -euo pipefail
5 +cd "$(dirname "$0")/.."
6 +set -a; [ -f .env.production ] && . ./.env.production; set +a
7 +DEST="${1:-$HOME/backups/chat-spboucher-ai}"; mkdir -p "$DEST"
8 +STAMP=$(date +%Y%m%d-%H%M%S)
9 +PASS_FILE="${BACKUP_PASSPHRASE_FILE:-$HOME/.config/chat-spboucher-ai/backup.pass}"
10 +if [ ! -f "$PASS_FILE" ]; then mkdir -p "$(dirname "$PASS_FILE")"; openssl rand -base64 48 > "$PASS_FILE"; chmod 600 "$PASS_FILE"; echo "Nouvelle phrase de passe de sauvegarde créée dans $PASS_FILE — copie-la dans ~/Desktop/Cluster/secrets/ (sans elle, les sauvegardes sont illisibles)."; fi
11 +export PATH="/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH"
12 +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
13 +pg_dump --no-owner --format=custom "$DATABASE_URL" > "$TMP/db.dump"
14 +tar -C "${DATA_DIR:-./data}" -czf "$TMP/uploads.tgz" uploads 2>/dev/null || tar -czf "$TMP/uploads.tgz" --files-from /dev/null
15 +tar -C "$TMP" -cf - db.dump uploads.tgz | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass "file:$PASS_FILE" -out "$DEST/csa-$STAMP.tar.enc"
16 +find "$DEST" -name 'csa-*.tar.enc' -mtime +14 -delete
17 +echo "backup ok: $DEST/csa-$STAMP.tar.enc ($(du -h "$DEST/csa-$STAMP.tar.enc" | cut -f1))"
added scripts/e2e/smoke.mjs +35 −0
@@ -0,0 +1,35 @@
1 +import { chromium, devices } from "playwright";
2 +import fs from "node:fs";
3 +const BASE = process.env.BASE ?? "http://localhost:8500";
4 +const PW = fs.readFileSync(process.env.HOME + "/Desktop/Cluster/secrets/chat-spboucher-ai.devpassword", "utf8").trim();
5 +fs.mkdirSync("e2e-shots", { recursive: true });
6 +const browser = await chromium.launch();
7 +async function login(page) {
8 + await page.goto(BASE + "/login"); await page.fill('input[autocomplete="username"]', "simon"); await page.fill('input[type="password"]', PW); await page.click('button[type="submit"]'); await page.waitForURL(BASE + "/", { timeout: 20000 });
9 +}
10 +// desktop
11 +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
12 +const page = await ctx.newPage(); const errors = []; page.on("pageerror", e => errors.push(e.message)); page.on("console", m => { if (m.type() === "error") errors.push(m.text()); });
13 +await login(page);
14 +await page.waitForSelector("textarea", { timeout: 20000 });
15 +await page.screenshot({ path: "e2e-shots/01-home.png" });
16 +// open model picker
17 +await page.waitForTimeout(1500); await page.click('[data-testid="model-picker"]');
18 +await page.waitForSelector("dialog[open]"); await page.screenshot({ path: "e2e-shots/02-model-picker.png" });
19 +await page.locator('dialog[open] button', { hasText: "Claude Haiku 5.5" }).first().click().catch(async () => { await page.keyboard.press("Escape"); });
20 +await page.fill("textarea", "Réponds en une phrase : quelle est la capitale du Québec ? Puis donne un tableau markdown de 2 lignes avec population approximative, et une formule LaTeX $E=mc^2$.");
21 +await page.keyboard.press("Enter");
22 +await page.waitForFunction(() => /\$/.test(document.body.innerText) && /→/.test(document.body.innerText), null, { timeout: 90000 }).catch(() => {});
23 +await page.waitForTimeout(1500);
24 +await page.screenshot({ path: "e2e-shots/03-answer.png", fullPage: false });
25 +console.log("URL after send:", page.url());
26 +// command palette
27 +await page.keyboard.press("Meta+k"); await page.waitForSelector("dialog[open]"); await page.fill('dialog[open] input', "Québec"); await page.waitForTimeout(1200); await page.screenshot({ path: "e2e-shots/04-palette.png" }); await page.keyboard.press("Escape");
28 +// mobile
29 +console.log("desktop errors:", JSON.stringify(errors.slice(0, 10), null, 1));
30 +const mctx = await browser.newContext({ ...devices["iPhone 15 Pro"] }); const mp = await mctx.newPage(); mp.on("pageerror", e => errors.push("mobile: " + e.message));
31 +await login(mp); await mp.waitForSelector("textarea"); await mp.screenshot({ path: "e2e-shots/05-mobile-home.png" });
32 +await mp.click('button[aria-label="Menu"]'); await mp.waitForTimeout(400); await mp.screenshot({ path: "e2e-shots/06-mobile-drawer.png" });
33 +const convLink = mp.locator('aside:visible a[href^="/c/"]').first(); if (await convLink.count()) { await convLink.click(); await mp.waitForTimeout(1500); await mp.screenshot({ path: "e2e-shots/07-mobile-conv.png" }); }
34 +console.log("page errors:", JSON.stringify(errors.slice(0, 10), null, 1));
35 +await browser.close();
added scripts/make-icons.ts +25 −0
@@ -0,0 +1,25 @@
1 +import { PNG } from "pngjs"; import fs from "node:fs";
2 +function icon(size: number, out: string, pad = 0.12) {
3 + const png = new PNG({ width: size, height: size });
4 + const r = size * 0.24; const x0 = size * pad, x1 = size * (1 - pad);
5 + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
6 + const i = (size * y + x) * 4;
7 + // rounded square dark background
8 + const inX = x >= x0 && x <= x1, inY = y >= x0 && y <= x1;
9 + let inside = inX && inY;
10 + if (inside) { const cx = Math.max(x0 + r, Math.min(x1 - r, x)), cy = Math.max(x0 + r, Math.min(x1 - r, y)); inside = (x - cx) ** 2 + (y - cy) ** 2 <= r * r; }
11 + if (!inside) { png.data[i + 3] = pad === 0 ? 255 : 0; png.data[i] = 10; png.data[i + 1] = 12; png.data[i + 2] = 16; continue; }
12 + const t = (x + y) / (2 * size); // gradient blue → amber
13 + png.data[i] = Math.round(0x6d + (0xf0 - 0x6d) * t * 0.35); png.data[i + 1] = Math.round(0x9c + (0xb3 - 0x9c) * t * 0.35); png.data[i + 2] = Math.round(0xff + (0x5b - 0xff) * t * 0.35); png.data[i + 3] = 255;
14 + // dark inner card
15 + const m = size * 0.2; if (x > m && x < size - m && y > m && y < size - m) { png.data[i] = 14; png.data[i + 1] = 17; png.data[i + 2] = 24; }
16 + // two dots
17 + for (const [dx, dy, cr, cg, cb] of [[0.34, 0.64, 0x6d, 0x9c, 0xff], [0.66, 0.36, 0xf0, 0xb3, 0x5b]] as const) if ((x - dx * size) ** 2 + (y - dy * size) ** 2 <= (size * 0.07) ** 2) { png.data[i] = cr; png.data[i + 1] = cg; png.data[i + 2] = cb; }
18 + // curve approx: thick line between dots
19 + const ax = 0.34 * size, ay = 0.64 * size, bx = 0.66 * size, by = 0.36 * size; const len2 = (bx - ax) ** 2 + (by - ay) ** 2; const tt = Math.max(0, Math.min(1, ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / len2)); const px = ax + tt * (bx - ax), py = ay + tt * (by - ay);
20 + if ((x - px) ** 2 + (y - py) ** 2 <= (size * 0.035) ** 2 && tt > 0.15 && tt < 0.85) { png.data[i] = 0x9f; png.data[i + 1] = 0xa8; png.data[i + 2] = 0xc8; }
21 + }
22 + fs.writeFileSync(out, PNG.sync.write(png));
23 +}
24 +icon(192, "public/icons/icon-192.png"); icon(512, "public/icons/icon-512.png"); icon(512, "public/icons/maskable-512.png", 0); icon(180, "public/icons/apple-touch-icon.png", 0);
25 +console.log("icons ok");
modified scripts/registry-refresh.ts +8 −0
@@ -62,6 +62,14 @@ async function main() {
62 62 }
63 63 for (const prov of ["openai", "anthropic"] as const) if (present[prov].length)
64 64 await sql`UPDATE models SET available = false, status = 'unavailable', hidden = true, unavailable_reason = 'No longer returned by the provider model listing', updated_at = now() WHERE provider = ${prov} AND available = true AND id <> ALL(${present[prov]})`;
65 + // default chat model: Claude Opus 5.5 when verified (100 % on the suite, mid price), else the economy/quality fallback is chosen in-app
66 + const cur = await sql<{ value: { defaultModel?: string } }[]>`SELECT value FROM settings WHERE key = 'models'`;
67 + const def = cur[0]?.value?.defaultModel;
68 + const defOk = def ? (await sql<{ ok: boolean }[]>`SELECT available AS ok FROM models WHERE id = ${def}`)[0]?.ok : false;
69 + if (!defOk) {
70 + const pick = (await sql<{ id: string }[]>`SELECT id FROM models WHERE available AND kind = 'chat' ORDER BY (id = 'claude-opus-5-5') DESC, (benchmark->>'qualityScore')::int DESC NULLS LAST, (pricing->>'input')::float ASC LIMIT 1`)[0]?.id;
71 + if (pick) await sql`INSERT INTO settings (key, value, updated_at) VALUES ('models', ${sql.json({ ...(cur[0]?.value ?? {}), defaultModel: pick })}, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`;
72 + }
65 73 await sql`INSERT INTO settings (key, value, updated_at) VALUES ('registry', ${sql.json({ refreshedAt: new Date().toISOString(), live: live.length, upserted, fresh })}, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`;
66 74 console.log(`registry: ${upserted} models upserted (${fresh} new/untested), openai=${present.openai.length} anthropic=${present.anthropic.length}`);
67 75 await sql.end();
added scripts/restore.sh +16 −0
@@ -0,0 +1,16 @@
1 +#!/bin/bash
2 +# Restore a backup produced by scripts/backup.sh.
3 +# Usage: scripts/restore.sh <csa-YYYYmmdd-HHMMSS.tar.enc> [--yes]
4 +set -euo pipefail
5 +cd "$(dirname "$0")/.."
6 +set -a; [ -f .env.production ] && . ./.env.production; set +a
7 +FILE="$1"; PASS_FILE="${BACKUP_PASSPHRASE_FILE:-$HOME/.config/chat-spboucher-ai/backup.pass}"
8 +[ "${2:-}" = "--yes" ] || { echo "Cette opération REMPLACE la base $DATABASE_URL et le dossier uploads. Relance avec --yes pour confirmer."; exit 1; }
9 +export PATH="/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH"
10 +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
11 +openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass "file:$PASS_FILE" -in "$FILE" | tar -C "$TMP" -xf -
12 +pm2 stop chat-spboucher-ai >/dev/null 2>&1 || true
13 +pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" "$TMP/db.dump"
14 +mkdir -p "${DATA_DIR:-./data}"; tar -C "${DATA_DIR:-./data}" -xzf "$TMP/uploads.tgz"
15 +pm2 start chat-spboucher-ai >/dev/null 2>&1 || true
16 +echo "restore ok"
modified scripts/setup-owner.ts +8 −0
@@ -23,6 +23,14 @@ function ask(q: string, hidden = false): Promise<string> {
23 23 }
24 24
25 25 async function main() {
26 + // Non-interactive mode for automation: OWNER_USERNAME + OWNER_PASSWORD env (never commit them).
27 + if (process.env.OWNER_PASSWORD) {
28 + if (process.env.OWNER_PASSWORD.length < 12) { console.error("OWNER_PASSWORD too short"); process.exit(1); }
29 + const h = await hash(process.env.OWNER_PASSWORD, { memoryCost: 65536, timeCost: 3, parallelism: 2, algorithm: 2 });
30 + await sql`INSERT INTO owner (id, username, password_hash) VALUES (1, ${process.env.OWNER_USERNAME ?? "simon"}, ${h}) ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, password_hash = EXCLUDED.password_hash, updated_at = now()`;
31 + await sql`UPDATE sessions SET revoked_at = now() WHERE revoked_at IS NULL`;
32 + console.log("Owner saved (non-interactive). All sessions revoked."); await sql.end(); return;
33 + }
26 34 const existing = await sql`SELECT username FROM owner WHERE id = 1`;
27 35 if (existing.length) console.log(`Owner already configured (${existing[0].username}). Continuing will RESET the password and revoke all sessions.`);
28 36 const username = (await ask(`Username [${existing[0]?.username ?? "simon"}]: `)).trim() || existing[0]?.username || "simon";
added src/app/(app)/c/[id]/page.tsx +6 −0
@@ -0,0 +1,6 @@
1 +import { ChatView } from "@/components/chat/chat-view";
2 +
3 +export default async function ConversationPage({ params }: { params: Promise<{ id: string }> }) {
4 + const { id } = await params;
5 + return <ChatView conversationId={id} />;
6 +}
added src/app/(app)/compare/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import { Suspense } from "react";
2 +import { CompareView } from "@/components/compare/compare-view";
3 +
4 +export const metadata = { title: "Comparer des modèles" };
5 +
6 +export default function ComparePage() {
7 + return <Suspense><CompareView /></Suspense>;
8 +}
added src/app/(app)/images/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { ImageStudio } from "@/components/images/image-studio";
2 +
3 +export default function ImagesPage() {
4 + return <div className="min-h-0 flex-1 overflow-y-auto"><ImageStudio /></div>;
5 +}
added src/app/(app)/layout.tsx +16 −0
@@ -0,0 +1,16 @@
1 +import { redirect } from "next/navigation";
2 +import { getSession } from "@/lib/auth";
3 +import { AppShell } from "@/components/app-shell";
4 +import { Toaster } from "@/components/ui";
5 +
6 +
7 +export default async function AppLayout({ children }: { children: React.ReactNode }) {
8 + const s = await getSession();
9 + if (!s) redirect("/login");
10 + return (
11 + <AppShell>
12 + {children}
13 + <Toaster />
14 + </AppShell>
15 + );
16 +}
added src/app/(app)/memory/page.tsx +77 −0
@@ -0,0 +1,77 @@
1 +"use client";
2 +import { useMemo, useState } from "react";
3 +import useSWR from "swr";
4 +import { Brain, Plus, Search, Download, ShieldCheck } from "lucide-react";
5 +import { fetcher } from "@/lib/client/fetcher";
6 +import type { MemoryDTO } from "@/lib/client/types";
7 +import { Button, Empty, Spinner, cn } from "@/components/ui";
8 +import { MemoryCard } from "@/components/memory/memory-card";
9 +import { AddMemoryDialog } from "@/components/memory/add-memory-dialog";
10 +import { CATEGORIES, MemoryToggles, categoryLabel } from "@/components/memory/memory-shared";
11 +
12 +interface Resp { memories: MemoryDTO[]; stats: { status: string; n: number }[] }
13 +
14 +export default function MemoryPage() {
15 + const [q, setQ] = useState(""); const [cat, setCat] = useState<string | null>(null); const [add, setAdd] = useState(false);
16 + const { data, isLoading, mutate } = useSWR<Resp>(`/api/memories${q ? `?q=${encodeURIComponent(q)}` : ""}`, fetcher, { keepPreviousData: true });
17 + const stat = (s: string) => data?.stats.find(x => x.status === s)?.n ?? 0;
18 + const candidates = useMemo(() => (data?.memories ?? []).filter(m => m.status === "candidate"), [data]);
19 + const grouped = useMemo(() => {
20 + const list = (data?.memories ?? []).filter(m => m.status !== "candidate" && (!cat || m.category === cat));
21 + const g = new Map<string, MemoryDTO[]>();
22 + for (const c of CATEGORIES) g.set(c.id, []);
23 + for (const m of list) { if (!g.has(m.category)) g.set(m.category, []); g.get(m.category)!.push(m); }
24 + return [...g.entries()].filter(([, v]) => v.length);
25 + }, [data, cat]);
26 + const refresh = () => void mutate();
27 +
28 + return (
29 + <div className="min-h-0 flex-1 overflow-y-auto">
30 + <div className="mx-auto w-full max-w-4xl px-4 py-6 md:px-8">
31 + <div className="mb-5 flex flex-wrap items-start justify-between gap-3">
32 + <div>
33 + <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"><Brain className="h-5 w-5 text-accent-2" /> Mémoire</h1>
34 + <p className="mt-1 text-[13px] text-muted">Faits durables réutilisés dans tes conversations. Tu vois tout, tu contrôles tout : rien n'est stocké en silence.</p>
35 + </div>
36 + <div className="flex gap-2">
37 + <a href="/api/memories/export?format=md" className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border px-3 text-sm text-fg-2 hover:bg-surface-2 hover:text-fg"><Download className="h-4 w-4" /> Markdown</a>
38 + <a href="/api/memories/export?format=json" className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border px-3 text-sm text-fg-2 hover:bg-surface-2 hover:text-fg"><Download className="h-4 w-4" /> JSON</a>
39 + <Button variant="primary" onClick={() => setAdd(true)}><Plus className="h-4 w-4" /> Ajouter</Button>
40 + </div>
41 + </div>
42 +
43 + <div className="mb-4 grid grid-cols-3 gap-2">
44 + {[{ l: "Actifs", v: stat("active"), tone: "text-ok" }, { l: "Candidats", v: stat("candidate"), tone: "text-warn" }, { l: "Désactivés", v: stat("disabled"), tone: "text-muted" }].map(t => (
45 + <div key={t.l} className="rounded-xl border border-border bg-surface px-4 py-3"><div className={cn("text-2xl font-semibold tabular-nums", t.tone)}>{t.v}</div><div className="text-[12px] text-muted">{t.l}</div></div>
46 + ))}
47 + </div>
48 + <div className="mb-6"><MemoryToggles /></div>
49 +
50 + {candidates.length > 0 && (
51 + <section className="mb-6">
52 + <h2 className="mb-2 flex items-center gap-2 text-[13px] font-semibold uppercase tracking-wider text-warn"><ShieldCheck className="h-4 w-4" /> Candidats à approuver · {candidates.length}</h2>
53 + <div className="space-y-2">{candidates.map(m => <MemoryCard key={m.id} m={m} candidate onChanged={refresh} />)}</div>
54 + </section>
55 + )}
56 +
57 + <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center">
58 + <div className="relative flex-1"><Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted" /><input value={q} onChange={e => setQ(e.target.value)} placeholder="Rechercher dans la mémoire…" className="input !pl-8" /></div>
59 + {isLoading && <Spinner />}
60 + </div>
61 + <div className="mb-4 flex flex-wrap gap-1.5">
62 + <button onClick={() => setCat(null)} className={cn("rounded-lg border px-2.5 py-1 text-[12px]", !cat ? "border-accent bg-accent/10 text-accent" : "border-border text-fg-2 hover:bg-surface-2")}>Toutes</button>
63 + {CATEGORIES.map(c => <button key={c.id} onClick={() => setCat(cat === c.id ? null : c.id)} className={cn("rounded-lg border px-2.5 py-1 text-[12px]", cat === c.id ? "border-accent bg-accent/10 text-accent" : "border-border text-fg-2 hover:bg-surface-2")}>{c.label}</button>)}
64 + </div>
65 +
66 + {data && !grouped.length && <Empty icon={<Brain className="h-8 w-8" />} title={q ? "Aucun souvenir ne correspond" : "Aucun souvenir pour l'instant"} hint="Les souvenirs apparaissent au fil des conversations (extraction automatique) ou quand tu en ajoutes un." action={<Button onClick={() => setAdd(true)}><Plus className="h-4 w-4" /> Ajouter un souvenir</Button>} />}
67 + {grouped.map(([c, items]) => (
68 + <section key={c} className="mb-6">
69 + <h2 className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-muted">{categoryLabel(c)} · {items.length}</h2>
70 + <div className="space-y-2">{items.map(m => <MemoryCard key={m.id} m={m} onChanged={refresh} />)}</div>
71 + </section>
72 + ))}
73 + </div>
74 + <AddMemoryDialog open={add} onClose={() => setAdd(false)} onAdded={refresh} />
75 + </div>
76 + );
77 +}
added src/app/(app)/models/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import { Suspense } from "react";
2 +import { ModelLab } from "@/components/models/model-lab";
3 +
4 +export const metadata = { title: "Laboratoire de modèles" };
5 +
6 +export default function ModelsPage() {
7 + return <Suspense><ModelLab /></Suspense>;
8 +}
added src/app/(app)/page.tsx +6 −0
@@ -0,0 +1,6 @@
1 +import { ChatView } from "@/components/chat/chat-view";
2 +
3 +export default async function Home({ searchParams }: { searchParams: Promise<{ project?: string }> }) {
4 + const sp = await searchParams;
5 + return <ChatView conversationId={null} projectId={sp.project ?? null} />;
6 +}
added src/app/(app)/projects/[id]/page.tsx +76 −0
@@ -0,0 +1,76 @@
1 +"use client";
2 +import { useEffect, useState } from "react";
3 +import { useParams, useRouter } from "next/navigation";
4 +import useSWR, { mutate as gmutate } from "swr";
5 +import { FolderKanban, Archive, Trash2, Check, AlertTriangle } from "lucide-react";
6 +import { api, fetcher } from "@/lib/client/fetcher";
7 +import type { ArtifactDTO, MemoryDTO, ProjectDTO } from "@/lib/client/types";
8 +import { Button, Spinner, Tabs, cn, toast } from "@/components/ui";
9 +import { ColorPicker } from "@/components/projects/project-shared";
10 +import { ArtifactsTab, ConversationsTab, FilesTab, InstructionsTab, MemoryTab, NotesTab, type ProjectFile } from "@/components/projects/project-tabs";
11 +
12 +interface Resp { project: ProjectDTO; files: ProjectFile[]; memories: MemoryDTO[]; artifacts: ArtifactDTO[] }
13 +type Tab = "conversations" | "instructions" | "files" | "memory" | "notes" | "artifacts";
14 +
15 +export default function ProjectPage() {
16 + const { id } = useParams<{ id: string }>(); const router = useRouter();
17 + const { data, error, mutate } = useSWR<Resp>(`/api/projects/${id}`, fetcher);
18 + const [tab, setTab] = useState<Tab>("conversations");
19 + const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [editingHead, setEditingHead] = useState(false);
20 + useEffect(() => { if (data) { setName(data.project.name); setDescription(data.project.description ?? ""); } }, [data]);
21 + const refresh = () => { void mutate(); void gmutate("/api/projects"); };
22 + async function patch(p: Record<string, unknown>, msg?: string) { try { await api(`/api/projects/${id}`, { method: "PATCH", json: p }); if (msg) toast(msg, "ok"); refresh(); } catch (e) { toast((e as Error).message, "error"); } }
23 + async function saveHead() { await patch({ name: name.trim() || data?.project.name, description: description.trim() || null }, "Projet mis à jour"); setEditingHead(false); }
24 + async function archive() { if (!confirm("Archiver ce projet ? Ses conversations restent accessibles dans l'historique.")) return; await patch({ archived: true }, "Projet archivé"); router.push("/"); }
25 + async function del() { if (!confirm("Supprimer définitivement ce projet ? Les conversations sont conservées mais détachées ; les fichiers et souvenirs du projet sont supprimés.")) return; await api(`/api/projects/${id}`, { method: "DELETE" }); void gmutate("/api/projects"); toast("Projet supprimé", "ok"); router.push("/"); }
26 +
27 + if (error) return <div className="p-8 text-sm text-danger">Projet introuvable.</div>;
28 + if (!data) return <div className="flex flex-1 items-center justify-center"><Spinner className="h-5 w-5" /></div>;
29 + const p = data.project;
30 + const tabs: { value: Tab; label: string; count?: number }[] = [
31 + { value: "conversations", label: "Conversations", count: p.conversation_count }, { value: "instructions", label: "Instructions" }, { value: "files", label: "Fichiers", count: data.files.length },
32 + { value: "memory", label: "Mémoire", count: data.memories.length }, { value: "notes", label: "Notes" }, { value: "artifacts", label: "Artefacts", count: data.artifacts.length },
33 + ];
34 + return (
35 + <div className="min-h-0 flex-1 overflow-y-auto">
36 + <div className="mx-auto w-full max-w-4xl px-4 py-6 md:px-8">
37 + <div className="mb-5 flex flex-wrap items-start gap-3">
38 + <span className="mt-1.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style={{ background: `${p.color ?? "#6d9cff"}22`, color: p.color ?? "var(--accent)" }}><FolderKanban className="h-5 w-5" /></span>
39 + <div className="min-w-0 flex-1">
40 + {editingHead ? (
41 + <div className="space-y-2">
42 + <input value={name} onChange={e => setName(e.target.value)} maxLength={120} className="input text-lg font-semibold" autoFocus />
43 + <input value={description} onChange={e => setDescription(e.target.value)} maxLength={2000} className="input text-[13px]" placeholder="Description" />
44 + <div className="flex items-center gap-3"><ColorPicker value={p.color} onChange={c => patch({ color: c })} /><span className="flex-1" /><Button variant="ghost" size="sm" onClick={() => setEditingHead(false)}>Annuler</Button><Button variant="primary" size="sm" onClick={saveHead}><Check className="h-3.5 w-3.5" /> Enregistrer</Button></div>
45 + </div>
46 + ) : (
47 + <button onClick={() => setEditingHead(true)} className="group block w-full text-left" title="Modifier le nom, la description et la couleur">
48 + <h1 className="truncate text-xl font-semibold tracking-tight group-hover:text-accent">{p.name}</h1>
49 + <p className="mt-0.5 text-[13px] text-muted">{p.description || "Ajouter une description…"}</p>
50 + </button>
51 + )}
52 + </div>
53 + </div>
54 +
55 + <div className="mb-5 -mx-1 overflow-x-auto px-1">
56 + <Tabs value={tab} onChange={setTab} items={tabs.map(t => ({ value: t.value, label: <span>{t.label}{t.count != null ? <span className="ml-1 text-muted">{t.count}</span> : null}</span> }))} />
57 + </div>
58 +
59 + <div className={cn("anim-fade-up")} key={tab}>
60 + {tab === "conversations" && <ConversationsTab project={p} />}
61 + {tab === "instructions" && <InstructionsTab project={p} onSaved={refresh} />}
62 + {tab === "files" && <FilesTab project={p} files={data.files} onChanged={refresh} />}
63 + {tab === "memory" && <MemoryTab project={p} memories={data.memories} onChanged={refresh} />}
64 + {tab === "notes" && <NotesTab project={p} />}
65 + {tab === "artifacts" && <ArtifactsTab artifacts={data.artifacts} />}
66 + </div>
67 +
68 + <section className="mt-12 rounded-2xl border border-danger/30 p-5">
69 + <h2 className="mb-1 flex items-center gap-2 text-[13px] font-semibold uppercase tracking-wider text-danger"><AlertTriangle className="h-4 w-4" /> Zone sensible</h2>
70 + <p className="mb-3 text-[13px] text-muted">Archiver masque le projet de la barre latérale sans rien supprimer. Supprimer efface les fichiers, notes et souvenirs du projet ; les conversations sont conservées sans projet.</p>
71 + <div className="flex flex-wrap gap-2"><Button variant="outline" onClick={archive}><Archive className="h-4 w-4" /> Archiver</Button><Button variant="danger" onClick={del}><Trash2 className="h-4 w-4" /> Supprimer le projet</Button></div>
72 + </section>
73 + </div>
74 + </div>
75 + );
76 +}
added src/app/(app)/projects/new/page.tsx +38 −0
@@ -0,0 +1,38 @@
1 +"use client";
2 +import { useState, type FormEvent } from "react";
3 +import { useRouter } from "next/navigation";
4 +import { mutate } from "swr";
5 +import { FolderKanban } from "lucide-react";
6 +import { api } from "@/lib/client/fetcher";
7 +import { useModels } from "@/lib/client/use-models";
8 +import { Button, Input, Textarea, toast } from "@/components/ui";
9 +import { ColorPicker, PROJECT_COLORS } from "@/components/projects/project-shared";
10 +
11 +export default function NewProjectPage() {
12 + const router = useRouter(); const { chat } = useModels();
13 + const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [instructions, setInstructions] = useState(""); const [color, setColor] = useState(PROJECT_COLORS[0]); const [defaultModel, setDefaultModel] = useState(""); const [busy, setBusy] = useState(false);
14 + async function submit(e: FormEvent) {
15 + e.preventDefault(); setBusy(true);
16 + try {
17 + const r = await api<{ project: { id: string } }>("/api/projects", { method: "POST", json: { name: name.trim(), description: description.trim() || undefined, instructions: instructions.trim() || undefined, color, defaultModel: defaultModel || null } });
18 + void mutate("/api/projects"); toast("Projet créé", "ok"); router.push(`/projects/${r.project.id}`);
19 + } catch (err) { toast((err as Error).message, "error"); setBusy(false); }
20 + }
21 + return (
22 + <div className="min-h-0 flex-1 overflow-y-auto">
23 + <form onSubmit={submit} className="mx-auto w-full max-w-2xl px-4 py-6 md:px-8">
24 + <h1 className="mb-1 flex items-center gap-2 text-xl font-semibold tracking-tight"><FolderKanban className="h-5 w-5 text-accent" /> Nouveau projet</h1>
25 + <p className="mb-6 text-[13px] text-muted">Un projet regroupe des conversations, des fichiers, des instructions, un modèle par défaut et une mémoire dédiée.</p>
26 + <div className="card space-y-4 p-5">
27 + <div><label className="mb-1 block text-xs font-medium text-fg-2">Nom</label><Input autoFocus required maxLength={120} value={name} onChange={e => setName(e.target.value)} placeholder="Ex. : Recherche UQO, Maclustr, Immobilier…" /></div>
28 + <div><label className="mb-1 block text-xs font-medium text-fg-2">Description</label><Input maxLength={2000} value={description} onChange={e => setDescription(e.target.value)} placeholder="Une ligne pour te rappeler l'objectif" /></div>
29 + <div><label className="mb-1 block text-xs font-medium text-fg-2">Couleur</label><ColorPicker value={color} onChange={setColor} /></div>
30 + <div><label className="mb-1 block text-xs font-medium text-fg-2">Modèle par défaut</label>
31 + <select value={defaultModel} onChange={e => setDefaultModel(e.target.value)} className="input"><option value="">— Suivre le choix du composeur —</option>{chat.map(m => <option key={m.id} value={m.id}>{m.displayName} ({m.provider})</option>)}</select></div>
32 + <div><label className="mb-1 block text-xs font-medium text-fg-2">Instructions du projet</label><Textarea value={instructions} onChange={e => setInstructions(e.target.value)} maxLength={20000} placeholder="Contexte, ton, contraintes… Ces instructions affinent le comportement du modèle sans jamais contourner les règles de sécurité." className="min-h-32 text-[14px]" /></div>
33 + </div>
34 + <div className="mt-4 flex justify-end gap-2"><Button type="button" variant="ghost" onClick={() => router.back()}>Annuler</Button><Button type="submit" variant="primary" loading={busy} disabled={!name.trim()}>Créer le projet</Button></div>
35 + </form>
36 + </div>
37 + );
38 +}
added src/app/(app)/research/page.tsx +9 −0
@@ -0,0 +1,9 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { ResearchView } from "@/components/research/research-view";
4 +
5 +export const metadata: Metadata = { title: "Recherche approfondie" };
6 +
7 +export default function ResearchPage() {
8 + return <Suspense><ResearchView /></Suspense>;
9 +}
added src/app/(app)/settings/page.tsx +48 −0
@@ -0,0 +1,48 @@
1 +"use client";
2 +import useSWR from "swr";
3 +import { Settings } from "lucide-react";
4 +import { api, fetcher } from "@/lib/client/fetcher";
5 +import { Spinner, toast } from "@/components/ui";
6 +import { MemoryToggles } from "@/components/memory/memory-shared";
7 +import { AppearanceSection, ModelsSection, SpendingSection, ToolsSection, IntegrationsSection, SecuritySection, InstallHint, Section, type Spending, type ToolInfo } from "@/components/settings/sections";
8 +
9 +interface Resp {
10 + settings: { memory?: Record<string, unknown>; spending?: Spending; routing?: { defaultMode?: string }; appearance?: Record<string, unknown>; registry?: { refreshedAt?: string; live?: number } };
11 + owner: { username: string; createdAt: string; totp: boolean } | null;
12 + tools: ToolInfo[];
13 + integrations: { openai: boolean; anthropic: boolean; tavily: boolean };
14 +}
15 +
16 +const NAV = [["apparence", "Apparence"], ["modeles", "Modèles"], ["depenses", "Dépenses"], ["memoire", "Mémoire"], ["outils", "Outils"], ["integrations", "Intégrations"], ["securite", "Sécurité"], ["apropos", "À propos"]];
17 +
18 +export default function SettingsPage() {
19 + const { data, mutate } = useSWR<Resp>("/api/settings", fetcher);
20 + async function save(key: string, value: unknown) { try { await api("/api/settings", { method: "PATCH", json: { key, value } }); toast("Réglages enregistrés", "ok"); void mutate(); } catch (e) { toast((e as Error).message, "error"); } }
21 + return (
22 + <div className="min-h-0 flex-1 overflow-y-auto">
23 + <div className="mx-auto flex w-full max-w-5xl gap-8 px-4 py-6 md:px-8">
24 + <nav className="sticky top-6 hidden w-40 shrink-0 self-start lg:block">{NAV.map(([id, l]) => <a key={id} href={`#${id}`} className="block rounded-lg px-3 py-1.5 text-[13px] text-fg-2 hover:bg-surface-2 hover:text-fg">{l}</a>)}</nav>
25 + <div className="min-w-0 flex-1 space-y-8">
26 + <div><h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"><Settings className="h-5 w-5 text-accent" /> Réglages</h1><p className="mt-1 text-[13px] text-muted">Apparence, modèles, dépenses, mémoire, outils et sécurité.</p></div>
27 + <InstallHint />
28 + {!data && <Spinner className="h-5 w-5" />}
29 + <AppearanceSection />
30 + {data && <ModelsSection routing={data.settings.routing ?? {}} onSave={save} />}
31 + {data && <SpendingSection value={data.settings.spending ?? { dailyLimitUsd: 10, monthlyLimitUsd: 150, alertAtPercent: 80 }} onSave={save} />}
32 + <Section id="memoire" title="Mémoire" hint="Les mêmes réglages que la page Mémoire. Les souvenirs restent visibles, modifiables et supprimables à tout moment."><MemoryToggles compact /><div className="text-[12px] text-muted">Gérer les souvenirs : <a href="/memory" className="text-accent hover:underline">Mémoire</a>.</div></Section>
33 + {data && <ToolsSection tools={data.tools} />}
34 + {data && <IntegrationsSection integrations={data.integrations} />}
35 + {data && <SecuritySection owner={data.owner} />}
36 + <Section id="apropos" title="À propos">
37 + <div className="space-y-1.5 text-[13px] text-fg-2">
38 + <div><span className="font-medium text-fg">Chat SPBoucher AI</span> · version 0.1.0</div>
39 + <div>Interface privée multi-fournisseurs (OpenAI, Anthropic ; MacLustr local à venir). Toutes les données — conversations, documents, mémoire, journaux d'outils — sont stockées localement sur le cluster MacLustr, jamais chez un tiers autre que les fournisseurs de modèles interrogés.</div>
40 + {data?.settings.registry?.refreshedAt && <div>Registre des modèles rafraîchi le {new Date(data.settings.registry.refreshedAt).toLocaleString("fr-CA")} ({data.settings.registry.live} identifiants listés).</div>}
41 + <div><a href="/models" className="text-accent hover:underline">Laboratoire de modèles et rapport de benchmark →</a></div>
42 + </div>
43 + </Section>
44 + </div>
45 + </div>
46 + </div>
47 + );
48 +}
added src/app/(app)/usage/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import type { Metadata } from "next";
2 +import { UsageDashboard } from "@/components/usage/dashboard";
3 +
4 +export const metadata: Metadata = { title: "Coûts & usage" };
5 +
6 +export default function UsagePage() {
7 + return <div className="min-h-0 flex-1 overflow-y-auto"><UsageDashboard /></div>;
8 +}
added src/app/api/artifacts/[id]/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { z } from "zod";
2 +import { guarded, json, body } from "@/lib/api";
3 +import { sql } from "@/lib/db";
4 +
5 +export const GET = guarded<{ id: string }>(async (req, { params }) => {
6 + const a = await sql`SELECT * FROM artifacts WHERE id = ${params.id}`;
7 + if (!a[0]) return json({ error: "Introuvable" }, { status: 404 });
8 + const versions = await sql`SELECT id, version, created_at, saved FROM artifacts WHERE root_id = ${a[0].root_id} ORDER BY version`;
9 + if (req.nextUrl.searchParams.get("raw") === "1") return new Response(a[0].content, { headers: { "content-type": "text/plain; charset=utf-8", "content-disposition": `attachment; filename="${String(a[0].title).replace(/[^\w.-]/g, "_")}.${ext(a[0].type, a[0].language)}"` } });
10 + return json({ artifact: a[0], versions });
11 +});
12 +
13 +export const PATCH = guarded<{ id: string }>(async (req, { params }) => {
14 + const b = await body(req, z.object({ saved: z.boolean().optional(), projectId: z.string().nullable().optional(), title: z.string().min(1).max(120).optional() }));
15 + await sql`UPDATE artifacts SET saved = COALESCE(${b.saved ?? null}, saved), title = COALESCE(${b.title ?? null}, title), project_id = CASE WHEN ${b.projectId !== undefined} THEN ${b.projectId ?? null} ELSE project_id END WHERE id = ${params.id}`;
16 + return json({ ok: true });
17 +});
18 +
19 +export const DELETE = guarded<{ id: string }>(async (_req, { params }) => { await sql`DELETE FROM artifacts WHERE id = ${params.id}`; return json({ ok: true }); });
20 +
21 +function ext(type: string, lang: string | null) { return ({ html: "html", svg: "svg", mermaid: "mmd", react: lang === "jsx" ? "jsx" : "tsx", python: "py", typescript: "ts", markdown: "md", table: "csv", chart: "json" } as Record<string, string>)[type] ?? "txt"; }
added src/app/api/artifacts/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { z } from "zod";
2 +import { guarded, json, body } from "@/lib/api";
3 +import { sql } from "@/lib/db";
4 +import { newId } from "@/lib/ids";
5 +
6 +export const GET = guarded(async (req) => {
7 + const p = req.nextUrl.searchParams;
8 + const conv = p.get("conversation"); const project = p.get("project"); const saved = p.get("saved") === "1";
9 + const rows = await sql`SELECT id, conversation_id, message_id, project_id, root_id, version, type, language, title, saved, created_at, length(content) AS size FROM artifacts
10 + WHERE (${!conv} OR conversation_id = ${conv ?? ""}) AND (${!project} OR project_id = ${project ?? ""}) AND (${!saved} OR saved) ORDER BY created_at DESC LIMIT 300`;
11 + return json({ artifacts: rows });
12 +});
13 +
14 +/** Create a new version of an artifact (edited content) or a brand-new artifact. */
15 +export const POST = guarded(async (req) => {
16 + const b = await body(req, z.object({ rootId: z.string().optional(), conversationId: z.string().nullable().optional(), projectId: z.string().nullable().optional(), type: z.string(), language: z.string().optional(), title: z.string().min(1).max(120), content: z.string().max(2_000_000), saved: z.boolean().optional() }));
17 + const id = newId("art");
18 + let version = 1; let root = id;
19 + if (b.rootId) { const prev = await sql<{ version: number }[]>`SELECT max(version)::int AS version FROM artifacts WHERE root_id = ${b.rootId}`; version = (prev[0]?.version ?? 0) + 1; root = b.rootId; }
20 + await sql`INSERT INTO artifacts (id, conversation_id, project_id, root_id, version, type, language, title, content, saved) VALUES (${id}, ${b.conversationId ?? null}, ${b.projectId ?? null}, ${root}, ${version}, ${b.type}, ${b.language ?? null}, ${b.title}, ${b.content}, ${b.saved ?? false})`;
21 + return json({ id, rootId: root, version }, { status: 201 });
22 +});
added src/app/api/attachments/[id]/route.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { guarded, json } from "@/lib/api";
2 +import { readAttachment, deleteAttachment, getAttachment } from "@/lib/storage";
3 +
4 +export const GET = guarded<{ id: string }>(async (req, { params }) => {
5 + if (req.nextUrl.searchParams.get("meta") === "1") { const a = await getAttachment(params.id); return a ? json({ attachment: { ...a, extracted_text: a.extracted_text?.slice(0, 20_000) } }) : json({ error: "Introuvable" }, { status: 404 }); }
6 + const a = await readAttachment(params.id);
7 + if (!a) return json({ error: "Introuvable" }, { status: 404 });
8 + const download = req.nextUrl.searchParams.get("download") === "1";
9 + return new Response(new Uint8Array(a.data), { headers: { "content-type": a.mime, "content-length": String(a.data.length), "cache-control": "private, max-age=3600", "content-disposition": `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(a.name)}`, "x-content-type-options": "nosniff" } });
10 +});
11 +
12 +export const DELETE = guarded<{ id: string }>(async (_req, { params }) => { await deleteAttachment(params.id); return json({ ok: true }); });
added src/app/api/attachments/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +import { guarded, json } from "@/lib/api";
2 +import { saveAttachment, extractText, allowedMime } from "@/lib/storage";
3 +import { indexAttachment } from "@/lib/tools/documents";
4 +import { env } from "@/lib/env";
5 +
6 +export const maxDuration = 120;
7 +
8 +/** multipart/form-data: file (repeatable), conversationId?, projectId? */
9 +export const POST = guarded(async (req) => {
10 + const form = await req.formData();
11 + const conversationId = (form.get("conversationId") as string | null) || null;
12 + const projectId = (form.get("projectId") as string | null) || null;
13 + const files = form.getAll("file").filter((f): f is File => f instanceof File);
14 + if (!files.length) return json({ error: "Aucun fichier" }, { status: 400 });
15 + const out = [];
16 + for (const f of files) {
17 + if (f.size > env.MAX_UPLOAD_MB * 1024 * 1024) return json({ error: `${f.name} dépasse ${env.MAX_UPLOAD_MB} Mo` }, { status: 413 });
18 + let mime = f.type || "application/octet-stream";
19 + if (mime === "application/octet-stream" || mime === "") { const ext = f.name.split(".").pop()?.toLowerCase(); mime = ({ md: "text/markdown", txt: "text/plain", csv: "text/csv", json: "application/json", pdf: "application/pdf", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", m4a: "audio/mp4", mp3: "audio/mpeg" } as Record<string, string>)[ext ?? ""] ?? mime; }
20 + if (!allowedMime(mime)) return json({ error: `Type non pris en charge : ${mime}` }, { status: 415 });
21 + const data = Buffer.from(await f.arrayBuffer());
22 + const ext = mime.startsWith("image/") || mime.startsWith("audio/") ? { text: null as string | null } : await extractText(data, mime, f.name);
23 + const att = await saveAttachment({ data, filename: f.name, mime, conversationId, projectId, extractedText: ext.text, pageCount: "pages" in ext ? ext.pages ?? null : null });
24 + if (ext.text && ext.text.length > 2000) void indexAttachment(att.id).catch(() => {});
25 + out.push({ id: att.id, filename: att.filename, mime: att.mime, size: att.size_bytes, pages: att.page_count, chars: ext.text?.length ?? 0 });
26 + }
27 + return json({ attachments: out }, { status: 201 });
28 +});
added src/app/api/auth/login/route.ts +23 −0
@@ -0,0 +1,23 @@
1 +import { NextResponse, type NextRequest } from "next/server";
2 +import { z } from "zod";
3 +import { sql } from "@/lib/db";
4 +import { verifyPassword, loginAllowed, recordAttempt, createSession, setSessionCookie, clientIp, assertSameOrigin, ownerExists } from "@/lib/auth";
5 +import { errorResponse } from "@/lib/api";
6 +
7 +export async function POST(req: NextRequest) {
8 + try {
9 + await assertSameOrigin();
10 + const ip = await clientIp();
11 + const gate = await loginAllowed(ip);
12 + if (!gate.ok) return NextResponse.json({ error: `Trop de tentatives. Réessaie dans ${gate.retryAfterSec} s.` }, { status: 429, headers: { "retry-after": String(gate.retryAfterSec) } });
13 + if (!(await ownerExists())) return NextResponse.json({ error: "Aucun propriétaire configuré. Exécute `npm run setup:owner` sur le serveur." }, { status: 503 });
14 + const { username, password } = z.object({ username: z.string().min(1).max(100), password: z.string().min(1).max(1000) }).parse(await req.json());
15 + const rows = await sql<{ username: string; password_hash: string }[]>`SELECT username, password_hash FROM owner WHERE id = 1`;
16 + const ok = rows[0] && rows[0].username.toLowerCase() === username.trim().toLowerCase() && (await verifyPassword(rows[0].password_hash, password));
17 + await recordAttempt(ip, !!ok);
18 + if (!ok) { await new Promise(r => setTimeout(r, 400 + Math.random() * 400)); return NextResponse.json({ error: "Identifiants invalides." }, { status: 401 }); }
19 + const token = await createSession({ userAgent: req.headers.get("user-agent"), ip });
20 + await setSessionCookie(token);
21 + return NextResponse.json({ ok: true });
22 + } catch (e) { return errorResponse(e); }
23 +}
added src/app/api/auth/logout/route.ts +9 −0
@@ -0,0 +1,9 @@
1 +import { NextResponse } from "next/server";
2 +import { getSession, revokeSession, clearSessionCookie } from "@/lib/auth";
3 +
4 +export async function POST() {
5 + const s = await getSession();
6 + if (s) await revokeSession(s.id);
7 + await clearSessionCookie();
8 + return NextResponse.json({ ok: true }, { headers: { "Clear-Site-Data": '"cache", "storage"' } });
9 +}
added src/app/api/auth/sessions/route.ts +11 −0
@@ -0,0 +1,11 @@
1 +import { z } from "zod";
2 +import { guarded, json, body } from "@/lib/api";
3 +import { listSessions, revokeSession, revokeAllSessions } from "@/lib/auth";
4 +
5 +export const GET = guarded(async (_req, { session }) => json({ sessions: await listSessions(session.id) }));
6 +
7 +export const DELETE = guarded(async (req, { session }) => {
8 + const { id, all } = await body(req, z.object({ id: z.string().optional(), all: z.boolean().optional() }));
9 + if (all) await revokeAllSessions(session.id); else if (id && id !== session.id) await revokeSession(id);
10 + return json({ ok: true });
11 +});
added src/app/api/benchmarks/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +import fs from "node:fs/promises";
2 +import path from "node:path";
3 +import { guarded, json } from "@/lib/api";
4 +import { env } from "@/lib/env";
5 +
6 +/** Serves the Phase Zero benchmark deliverables to the model laboratory. */
7 +export const GET = guarded(async (req) => {
8 + const file = req.nextUrl.searchParams.get("file") ?? "capability-matrix.json";
9 + const allowed = ["capability-matrix.json", "benchmark-report.md", "routing-recommendations.md", "routing-recommendations.json", "model-discovery.json", "openai-results.json", "anthropic-results.json", "modality-results.json", "pricing.json"];
10 + if (!allowed.includes(file)) return json({ error: "fichier inconnu" }, { status: 400 });
11 + try {
12 + const data = await fs.readFile(path.join(env.BENCHMARKS_DIR, file), "utf8");
13 + return new Response(data, { headers: { "content-type": file.endsWith(".md") ? "text/markdown; charset=utf-8" : "application/json" } });
14 + } catch { return json({ error: "benchmark introuvable" }, { status: 404 }); }
15 +});
added src/app/api/chat/approve/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { z } from "zod";
2 +import { guarded, body, sse } from "@/lib/api";
3 +import { getMessage, updateMessage, deactivateFrom } from "@/lib/chat/store";
4 +import { runAssistant } from "@/lib/chat/engine";
5 +import type { Part } from "@/lib/ai/types";
6 +
7 +export const maxDuration = 900;
8 +
9 +/** Approve or deny tool calls left pending on an assistant message, then continue the turn. */
10 +export const POST = guarded(async (req) => {
11 + const b = await body(req, z.object({ messageId: z.string(), approve: z.array(z.string()), deny: z.array(z.string()).optional() }));
12 + const m = await getMessage(b.messageId);
13 + if (!m || m.role !== "assistant" || !m.parent_id) return Response.json({ error: "Message introuvable" }, { status: 404 });
14 + const parts = (m.parts as Part[]).map(p => p.type === "tool_call" && p.status === "pending_approval" && b.deny?.includes(p.id) ? { ...p, status: "denied" as const, error: "Refusé par le propriétaire" } : p);
15 + await updateMessage(m.id, { parts, status: "complete" });
16 + // The continuation is a fresh assistant turn that sees the previous partial answer + tool decisions.
17 + await deactivateFrom(m.conversation_id, m.id, false);
18 + return sse(async (emit, signal) => {
19 + emit("conversation", { id: m.conversation_id, userMessageId: m.parent_id, continued: m.id });
20 + await runAssistant({ conversationId: m.conversation_id, modelId: m.model_id ?? undefined, routingMode: "manual", parentMessageId: m.id, signal, emit, approvedToolCallIds: b.approve });
21 + }, req.signal);
22 +});
added src/app/api/chat/edit/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { z } from "zod";
2 +import { guarded, body, sse } from "@/lib/api";
3 +import { getMessage, deactivateFrom, insertMessage } from "@/lib/chat/store";
4 +import { runAssistant } from "@/lib/chat/engine";
5 +import type { Part } from "@/lib/ai/types";
6 +
7 +export const maxDuration = 900;
8 +
9 +/** Edit a user message: creates a sibling user message (same parent), deactivates the old branch, and answers it. */
10 +export const POST = guarded(async (req) => {
11 + const b = await body(req, z.object({ messageId: z.string(), text: z.string().min(1).max(200_000), modelId: z.string().optional(), routingMode: z.enum(["manual", "fast", "quality", "economy", "auto"]).optional(), toolsEnabled: z.boolean().optional() }));
12 + const m = await getMessage(b.messageId);
13 + if (!m || m.role !== "user") return Response.json({ error: "Message utilisateur introuvable" }, { status: 404 });
14 + await deactivateFrom(m.conversation_id, m.id, true);
15 + const keep = (m.parts as Part[]).filter(p => p.type !== "text");
16 + const user = await insertMessage({ conversationId: m.conversation_id, parentId: m.parent_id, role: "user", parts: [...keep, { type: "text", text: b.text }] });
17 + return sse(async (emit, signal) => {
18 + emit("conversation", { id: m.conversation_id, userMessageId: user.id, edited: m.id });
19 + await runAssistant({ conversationId: m.conversation_id, modelId: b.modelId, routingMode: b.routingMode, toolsEnabled: b.toolsEnabled, parentMessageId: user.id, signal, emit });
20 + }, req.signal);
21 +});
added src/app/api/chat/regenerate/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { z } from "zod";
2 +import { guarded, body, sse } from "@/lib/api";
3 +import { getMessage, deactivateFrom } from "@/lib/chat/store";
4 +import { runAssistant } from "@/lib/chat/engine";
5 +
6 +export const maxDuration = 900;
7 +
8 +/** Regenerate an assistant message (optionally with another model). The previous answer stays as an inactive sibling. */
9 +export const POST = guarded(async (req) => {
10 + const b = await body(req, z.object({ messageId: z.string(), modelId: z.string().optional(), routingMode: z.enum(["manual", "fast", "quality", "economy", "auto"]).optional(), settings: z.record(z.string(), z.unknown()).optional(), toolsEnabled: z.boolean().optional() }));
11 + const m = await getMessage(b.messageId);
12 + if (!m || m.role !== "assistant" || !m.parent_id) return Response.json({ error: "Message assistant introuvable" }, { status: 404 });
13 + await deactivateFrom(m.conversation_id, m.id, true);
14 + return sse(async (emit, signal) => {
15 + emit("conversation", { id: m.conversation_id, userMessageId: m.parent_id, regenerated: m.id });
16 + await runAssistant({ conversationId: m.conversation_id, modelId: b.modelId ?? m.model_id ?? undefined, routingMode: b.routingMode ?? "manual", settings: b.settings as never, toolsEnabled: b.toolsEnabled, parentMessageId: m.parent_id!, signal, emit });
17 + }, req.signal);
18 +});
added src/app/api/chat/route.ts +43 −0
@@ -0,0 +1,43 @@
1 +import { z } from "zod";
2 +import { guarded, body, sse } from "@/lib/api";
3 +import { createConversation, getConversation, insertMessage, getActiveMessages } from "@/lib/chat/store";
4 +import { runAssistant } from "@/lib/chat/engine";
5 +import { sql } from "@/lib/db";
6 +import type { Part } from "@/lib/ai/types";
7 +
8 +export const maxDuration = 900;
9 +
10 +const schema = z.object({
11 + conversationId: z.string().optional(),
12 + projectId: z.string().nullable().optional(),
13 + text: z.string().max(200_000),
14 + attachmentIds: z.array(z.string()).max(20).optional(),
15 + modelId: z.string().optional(),
16 + routingMode: z.enum(["manual", "fast", "quality", "economy", "auto"]).optional(),
17 + settings: z.object({ effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(), maxOutputTokens: z.number().int().min(256).max(128_000).optional(), temperature: z.number().min(0).max(2).optional(), reasoningSummary: z.boolean().optional() }).optional(),
18 + toolsEnabled: z.boolean().optional(),
19 + toolNames: z.array(z.string()).optional(),
20 +});
21 +
22 +export const POST = guarded(async (req) => {
23 + const b = await body(req, schema);
24 + let conv = b.conversationId ? await getConversation(b.conversationId) : null;
25 + if (b.conversationId && !conv) return Response.json({ error: "Conversation introuvable" }, { status: 404 });
26 + if (!conv) conv = await createConversation({ projectId: b.projectId ?? null, modelId: b.modelId ?? null, settings: { routingMode: b.routingMode ?? (b.modelId ? "manual" : "auto"), toolsEnabled: b.toolsEnabled ?? true } });
27 + const parts: Part[] = [];
28 + if (b.attachmentIds?.length) {
29 + const atts = await sql<{ id: string; mime: string; filename: string; size_bytes: number; page_count: number | null }[]>`SELECT id, mime, filename, size_bytes, page_count FROM attachments WHERE id = ANY(${b.attachmentIds})`;
30 + for (const a of atts) parts.push(a.mime.startsWith("image/") ? { type: "image", attachmentId: a.id, mime: a.mime, name: a.filename } : { type: "file", attachmentId: a.id, mime: a.mime, name: a.filename, size: a.size_bytes, pages: a.page_count ?? undefined });
31 + }
32 + if (b.text.trim()) parts.push({ type: "text", text: b.text });
33 + if (!parts.length) return Response.json({ error: "Message vide" }, { status: 400 });
34 + const prev = await getActiveMessages(conv.id);
35 + const user = await insertMessage({ conversationId: conv.id, parentId: prev.at(-1)?.id ?? null, role: "user", parts });
36 + if (b.attachmentIds?.length) await sql`UPDATE attachments SET conversation_id = ${conv.id}, message_id = ${user.id} WHERE id = ANY(${b.attachmentIds})`;
37 + if (b.modelId && b.modelId !== conv.model_id) await sql`UPDATE conversations SET model_id = ${b.modelId} WHERE id = ${conv.id}`;
38 + const convId = conv.id;
39 + return sse(async (emit, signal) => {
40 + emit("conversation", { id: convId, userMessageId: user.id, created: !b.conversationId });
41 + await runAssistant({ conversationId: convId, modelId: b.modelId, routingMode: b.routingMode, settings: b.settings, toolsEnabled: b.toolsEnabled, toolNames: b.toolNames, parentMessageId: user.id, signal, emit });
42 + }, req.signal);
43 +});
added src/app/api/compare/judge/route.ts +34 −0
@@ -0,0 +1,34 @@
1 +import { z } from "zod";
2 +import { guarded, body, json } from "@/lib/api";
3 +import { adapterFor, estimateCost } from "@/lib/ai";
4 +import { requireChatModel } from "@/lib/ai/registry";
5 +import { recordUsage } from "@/lib/usage";
6 +import { sql } from "@/lib/db";
7 +
8 +export const maxDuration = 300;
9 +
10 +/** AI-assisted comparison — explicitly a model-generated opinion, not ground truth. */
11 +export const POST = guarded(async (req) => {
12 + const b = await body(req, z.object({ id: z.string(), judgeModel: z.string() }));
13 + const rows = await sql<{ prompt: string; results: { modelId: string; text: string; error: string | null }[] }[]>`SELECT prompt, results FROM comparisons WHERE id = ${b.id}`;
14 + if (!rows[0]) return json({ error: "Introuvable" }, { status: 404 });
15 + const judge = await requireChatModel(b.judgeModel);
16 + const answers = rows[0].results.filter(r => !r.error && r.text);
17 + const labels = answers.map((_, i) => String.fromCharCode(65 + i));
18 + const prompt = `You are evaluating ${answers.length} anonymous answers to the same prompt. Judge accuracy, completeness, clarity and adherence to the request. Be specific and cite short excerpts. Then output a final line exactly: RANKING: ${labels.join(" > ")} (ordered best to worst) and a line SCORES: ${labels.map(l => `${l}=<0-10>`).join(", ")}.
19 +
20 +PROMPT:
21 +${rows[0].prompt.slice(0, 6000)}
22 +
23 +${answers.map((a, i) => `=== ANSWER ${labels[i]} ===\n${a.text.slice(0, 12_000)}`).join("\n\n")}`;
24 + let text = ""; let usage = { input: 0, output: 0 }; const t0 = Date.now();
25 + for await (const ev of adapterFor(judge.provider).stream({ model: judge, messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], settings: { maxOutputTokens: 4000, reasoningSummary: false }, loadAttachment: async () => null })) {
26 + if (ev.type === "text") text += ev.delta; else if (ev.type === "usage") usage = ev.usage; else if (ev.type === "error") return json({ error: ev.message }, { status: 502 });
27 + }
28 + const cost = estimateCost(judge, usage);
29 + await recordUsage({ provider: judge.provider, modelId: judge.id, kind: "judge", usage, costEstimated: cost, latencyMs: Date.now() - t0, meta: { comparisonId: b.id } });
30 + const mapping = Object.fromEntries(labels.map((l, i) => [l, answers[i].modelId]));
31 + const result = { judgeModel: judge.id, text, mapping, costUsd: cost, disclaimer: "Évaluation générée par un modèle : une opinion, pas une vérité objective.", at: new Date().toISOString() };
32 + await sql`UPDATE comparisons SET judge = ${sql.json(result as never)} WHERE id = ${b.id}`;
33 + return json({ judge: result });
34 +});
added src/app/api/compare/route.ts +55 −0
@@ -0,0 +1,55 @@
1 +import { z } from "zod";
2 +import { guarded, body, sse, json } from "@/lib/api";
3 +import { adapterFor, estimateCost } from "@/lib/ai";
4 +import { requireChatModel } from "@/lib/ai/registry";
5 +import { recordUsage, assertWithinBudget } from "@/lib/usage";
6 +import { sql } from "@/lib/db";
7 +import { newId } from "@/lib/ids";
8 +import { buildSystemPrompt } from "@/lib/chat/system";
9 +import { readAttachment } from "@/lib/storage";
10 +
11 +export const maxDuration = 900;
12 +
13 +/** POST: same prompt to N models concurrently; streams per-model events. GET: history. */
14 +export const POST = guarded(async (req) => {
15 + const b = await body(req, z.object({ prompt: z.string().min(1).max(100_000), modelIds: z.array(z.string()).min(2).max(6), projectId: z.string().nullable().optional(), attachmentIds: z.array(z.string()).optional(), settings: z.object({ effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(), maxOutputTokens: z.number().int().optional() }).optional(), system: z.string().max(20_000).optional() }));
16 + await assertWithinBudget();
17 + const models = await Promise.all(b.modelIds.map(id => requireChatModel(id)));
18 + const id = newId("cmp");
19 + const { system } = await buildSystemPrompt({ modelId: "(comparison)", projectId: b.projectId, query: b.prompt, toolsEnabled: false, conversationSettings: b.system ? { systemPrompt: b.system } : undefined });
20 + const parts = [...(b.attachmentIds ?? []).map(a => ({ type: "image" as const, attachmentId: a, mime: "image/png" })), { type: "text" as const, text: b.prompt }];
21 + return sse(async (emit, signal) => {
22 + emit("start", { id, models: models.map(m => ({ id: m.id, provider: m.provider })) });
23 + const results = await Promise.all(models.map(async m => {
24 + const t0 = Date.now(); let ttft: number | null = null; let text = ""; let reasoning = ""; let usage = { input: 0, output: 0, cached: 0, reasoning: 0 }; let error: string | null = null;
25 + try {
26 + for await (const ev of adapterFor(m.provider).stream({ model: m, system, messages: [{ role: "user", parts }], settings: { ...b.settings, maxOutputTokens: b.settings?.maxOutputTokens ?? 8000 }, signal, loadAttachment: readAttachment })) {
27 + if (ev.type === "text") { if (ttft === null) ttft = Date.now() - t0; text += ev.delta; emit("text", { model: m.id, delta: ev.delta }); }
28 + else if (ev.type === "reasoning") { reasoning += ev.delta; emit("reasoning", { model: m.id, delta: ev.delta }); }
29 + else if (ev.type === "usage") usage = { input: usage.input + ev.usage.input, output: usage.output + ev.usage.output, cached: usage.cached + (ev.usage.cached ?? 0), reasoning: usage.reasoning + (ev.usage.reasoning ?? 0) };
30 + else if (ev.type === "error") throw new Error(ev.message);
31 + }
32 + } catch (e) { error = (e as Error).message; emit("model_error", { model: m.id, message: error }); }
33 + const latencyMs = Date.now() - t0; const costUsd = estimateCost(m, usage);
34 + await recordUsage({ provider: m.provider, modelId: m.id, kind: "compare", projectId: b.projectId ?? null, usage, costEstimated: costUsd, latencyMs, ttftMs: ttft, status: error ? "error" : "ok", error, meta: { comparisonId: id } });
35 + const r = { modelId: m.id, provider: m.provider, text, reasoning: reasoning || undefined, usage, costUsd, latencyMs, ttftMs: ttft, error };
36 + emit("model_done", r);
37 + return r;
38 + }));
39 + await sql`INSERT INTO comparisons (id, project_id, prompt, model_ids, results) VALUES (${id}, ${b.projectId ?? null}, ${b.prompt}, ${b.modelIds}, ${sql.json(results as never)})`;
40 + emit("done", { id });
41 + }, req.signal);
42 +});
43 +
44 +export const GET = guarded(async (req) => {
45 + const id = req.nextUrl.searchParams.get("id");
46 + if (id) { const r = await sql`SELECT * FROM comparisons WHERE id = ${id}`; return json({ comparison: r[0] ?? null }); }
47 + const rows = await sql`SELECT id, prompt, model_ids, preferred_model, created_at, (SELECT coalesce(sum((x->>'costUsd')::numeric),0) FROM jsonb_array_elements(results) x) AS cost FROM comparisons ORDER BY created_at DESC LIMIT 100`;
48 + return json({ comparisons: rows });
49 +});
50 +
51 +export const PATCH = guarded(async (req) => {
52 + const b = await body(req, z.object({ id: z.string(), preferredModel: z.string().optional() }));
53 + await sql`UPDATE comparisons SET preferred_model = COALESCE(${b.preferredModel ?? null}, preferred_model) WHERE id = ${b.id}`;
54 + return json({ ok: true });
55 +});
added src/app/api/conversations/[id]/branch/route.ts +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/conversations/[id]/export/route.ts +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/conversations/[id]/route.ts +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/conversations/route.ts +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/health/route.ts +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/images/route.ts +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/memories/[id]/route.ts +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/memories/export/route.ts +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/memories/route.ts +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/models/refresh/route.ts +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/models/route.ts +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/projects/[id]/route.ts +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/projects/route.ts +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/research/[id]/route.ts +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/research/route.ts +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/search/route.ts +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/settings/route.ts +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/stt/route.ts +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/tts/route.ts +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added src/app/api/usage/route.ts +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
modified src/app/globals.css +135 −16
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
modified src/app/layout.tsx +19 −18
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added src/app/login/page.tsx +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added src/app/offline/page.tsx +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
deleted src/app/page.tsx +0 −69
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added src/components/app-shell.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added src/components/chat/artifact-panel.tsx +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added src/components/chat/chat-view.tsx +122 −0
Large diff (122 lines) is not loaded by default to keep the page fast.Load diff
added src/components/chat/composer.tsx +84 −0
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
added src/components/chat/markdown.tsx +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added src/components/chat/message.tsx +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added src/components/chat/model-picker.tsx +81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added src/components/chat/tool-call.tsx +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added src/components/command-palette.tsx +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added src/components/compare/compare-view.tsx +208 −0
Large diff (208 lines) is not loaded by default to keep the page fast.Load diff
added src/components/images/image-studio.tsx +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added src/components/logo.tsx +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added src/components/memory/add-memory-dialog.tsx +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added src/components/memory/memory-card.tsx +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added src/components/memory/memory-shared.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added src/components/models/model-lab.tsx +158 −0
Large diff (158 lines) is not loaded by default to keep the page fast.Load diff
added src/components/projects/project-shared.tsx +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added src/components/projects/project-tabs.tsx +145 −0
Large diff (145 lines) is not loaded by default to keep the page fast.Load diff
added src/components/research/research-view.tsx +151 −0
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
added src/components/settings/sections.tsx +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added src/components/sidebar.tsx +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added src/components/ui.tsx +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added src/components/usage/dashboard.tsx +168 −0
Large diff (168 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/ai/anthropic.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/ai/router.ts +9 −1
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/api.ts +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/chat/engine.ts +159 −0
Large diff (159 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/chat/post.ts +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/chat/store.ts +126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/chat/system.ts +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/client/fetcher.ts +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/client/prefs.ts +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/client/types.ts +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/client/use-chat.ts +128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/client/use-models.ts +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/research/engine.ts +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/storage.ts +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/tools/documents.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/tools/image.ts +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/tools/index.ts +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/tools/memory.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/tools/python.ts +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/tools/web.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/usage.ts +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added tsconfig.tsbuildinfo +1 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme