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 const eslintConfig = defineConfig([ 5 const eslintConfig = defineConfig([
6 ...nextVitals, 6 ...nextVitals,
7 ...nextTs, 7 ...nextTs,
8− // Override default ignores of eslint-config-next. 8+ globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "scripts/**", "public/sw.js", "e2e-shots/**"]),
9− globalIgnores([ 9+ {
10− // Default ignores of eslint-config-next: 10+ rules: {
11− ".next/**", 11+ // French UI copy is full of apostrophes; JSX text is already escaped by React.
12− "out/**", 12+ "react/no-unescaped-entities": "off",
13− "build/**", 13+ // Images come from our own authenticated API (dynamic, private) — next/image optimisation is not applicable.
14− "next-env.d.ts", 14+ "@next/next/no-img-element": "off",
15− ]), 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 export default eslintConfig; 23 export default eslintConfig;
modified next-env.d.ts +1 −0
@@ -1,6 +1,7 @@
1 /// <reference types="next" /> 1 /// <reference types="next" />
2 /// <reference types="next/image-types/global" /> 2 /// <reference types="next/image-types/global" />
3 import "./.next/types/routes.d.ts"; 3 import "./.next/types/routes.d.ts";
4+import "./.next/types/root-params.d.ts";
4 5
5 // NOTE: This file should not be edited 6 // NOTE: This file should not be edited
6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 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 import type { NextConfig } from "next"; 1 import type { NextConfig } from "next";
2 2
3 const nextConfig: NextConfig = { 3 const nextConfig: NextConfig = {
4− /* config options here */ 4+ reactStrictMode: true,
5− cacheComponents: true, 5+ poweredByHeader: false,
6− partialPrefetching: true, 6+ output: "standalone",
7− turbopack: { 7+ serverExternalPackages: ["pdf-parse", "mammoth", "xlsx", "@node-rs/argon2", "postgres"],
8− rules: { 8+ experimental: { serverActions: { bodySizeLimit: "60mb" } },
9− "*.css": { 9+ async headers() {
10− loaders: ["@tailwindcss/turbopack"], 10+ return [{ source: "/(.*)", headers: [
11− as: "*.css", 11+ { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
12− }, 12+ { key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=(self)" },
13− }, 13+ ] }];
14 }, 14 },
15 }; 15 };
16 16
modified package.json +3 −2
@@ -46,7 +46,6 @@
46 "remark-math": "^6.0.0", 46 "remark-math": "^6.0.0",
47 "swr": "^2.5.1", 47 "swr": "^2.5.1",
48 "tsx": "^4.23.15", 48 "tsx": "^4.23.15",
49− "typescript": "^7.0.2",
50 "xlsx": "^0.18.5", 49 "xlsx": "^0.18.5",
51 "zod": "^4.6.7" 50 "zod": "^4.6.7"
52 }, 51 },
@@ -58,6 +57,8 @@
58 "@types/react-dom": "^19.3.0", 57 "@types/react-dom": "^19.3.0",
59 "eslint": "^10.14.0", 58 "eslint": "^10.14.0",
60 "eslint-config-next": "16.4.3", 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 tsx: 95 tsx:
96 specifier: ^4.23.15 96 specifier: ^4.23.15
97 version: 4.23.15 97 version: 4.23.15
98− typescript:
99− specifier: ^7.0.2
100− version: 7.0.2
101 xlsx: 98 xlsx:
102 specifier: ^0.18.5 99 specifier: ^0.18.5
103 version: 0.18.5 100 version: 0.18.5
@@ -122,10 +119,16 @@ importers:
122 version: 10.14.0(jiti@2.7.0) 119 version: 10.14.0(jiti@2.7.0)
123 eslint-config-next: 120 eslint-config-next:
124 specifier: 16.4.3 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 tailwindcss: 126 tailwindcss:
127 specifier: ^4.3.3 127 specifier: ^4.3.3
128 version: 4.3.3 128 version: 4.3.3
129+ typescript:
130+ specifier: ^5.9.3
131+ version: 5.9.3
129 132
130 packages: 133 packages:
131 134
@@ -1236,126 +1239,6 @@ packages:
1236 resolution: {integrity: sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==} 1239 resolution: {integrity: sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==}
1237 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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 '@ungap/structured-clone@1.4.0': 1242 '@ungap/structured-clone@1.4.0':
1360 resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} 1243 resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
1361 1244
@@ -3050,6 +2933,16 @@ packages:
3050 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} 2933 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
3051 engines: {node: '>=12'} 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 pngjs@7.0.0: 2946 pngjs@7.0.0:
3054 resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} 2947 resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
3055 engines: {node: '>=14.19.0'} 2948 engines: {node: '>=14.19.0'}
@@ -3443,9 +3336,9 @@ packages:
3443 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 3336 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
3444 typescript: '>=4.8.4 <6.1.0' 3337 typescript: '>=4.8.4 <6.1.0'
3445 3338
3446− typescript@7.0.2: 3339+ typescript@5.9.3:
3447− resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} 3340+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
3448− engines: {node: '>=16.20.0'} 3341+ engines: {node: '>=14.17'}
3449 hasBin: true 3342 hasBin: true
3450 3343
3451 unbox-primitive@1.1.0: 3344 unbox-primitive@1.1.0:
@@ -4451,40 +4344,40 @@ snapshots:
4451 4344
4452 '@types/use-sync-external-store@0.0.6': {} 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 dependencies: 4348 dependencies:
4456 '@eslint-community/regexpp': 4.12.2 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 '@typescript-eslint/scope-manager': 8.72.1 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) 4352+ '@typescript-eslint/type-utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
4460− '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2) 4353+ '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
4461 '@typescript-eslint/visitor-keys': 8.72.1 4354 '@typescript-eslint/visitor-keys': 8.72.1
4462 eslint: 10.14.0(jiti@2.7.0) 4355 eslint: 10.14.0(jiti@2.7.0)
4463 ignore: 7.0.14 4356 ignore: 7.0.14
4464 natural-compare: 1.4.0 4357 natural-compare: 1.4.0
4465− ts-api-utils: 2.5.0(typescript@7.0.2) 4358+ ts-api-utils: 2.5.0(typescript@5.9.3)
4466− typescript: 7.0.2 4359+ typescript: 5.9.3
4467 transitivePeerDependencies: 4360 transitivePeerDependencies:
4468 - supports-color 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 dependencies: 4364 dependencies:
4472 '@typescript-eslint/scope-manager': 8.72.1 4365 '@typescript-eslint/scope-manager': 8.72.1
4473 '@typescript-eslint/types': 8.72.1 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 '@typescript-eslint/visitor-keys': 8.72.1 4368 '@typescript-eslint/visitor-keys': 8.72.1
4476 debug: 4.4.3 4369 debug: 4.4.3
4477 eslint: 10.14.0(jiti@2.7.0) 4370 eslint: 10.14.0(jiti@2.7.0)
4478− typescript: 7.0.2 4371+ typescript: 5.9.3
4479 transitivePeerDependencies: 4372 transitivePeerDependencies:
4480 - supports-color 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 dependencies: 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 '@typescript-eslint/types': 8.72.1 4378 '@typescript-eslint/types': 8.72.1
4486 debug: 4.4.3 4379 debug: 4.4.3
4487− typescript: 7.0.2 4380+ typescript: 5.9.3
4488 transitivePeerDependencies: 4381 transitivePeerDependencies:
4489 - supports-color 4382 - supports-color
4490 4383
@@ -4493,47 +4386,47 @@ snapshots:
4493 '@typescript-eslint/types': 8.72.1 4386 '@typescript-eslint/types': 8.72.1
4494 '@typescript-eslint/visitor-keys': 8.72.1 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 dependencies: 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 dependencies: 4394 dependencies:
4502 '@typescript-eslint/types': 8.72.1 4395 '@typescript-eslint/types': 8.72.1
4503− '@typescript-eslint/typescript-estree': 8.72.1(typescript@7.0.2) 4396+ '@typescript-eslint/typescript-estree': 8.72.1(typescript@5.9.3)
4504− '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2) 4397+ '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
4505 debug: 4.4.3 4398 debug: 4.4.3
4506 eslint: 10.14.0(jiti@2.7.0) 4399 eslint: 10.14.0(jiti@2.7.0)
4507− ts-api-utils: 2.5.0(typescript@7.0.2) 4400+ ts-api-utils: 2.5.0(typescript@5.9.3)
4508− typescript: 7.0.2 4401+ typescript: 5.9.3
4509 transitivePeerDependencies: 4402 transitivePeerDependencies:
4510 - supports-color 4403 - supports-color
4511 4404
4512 '@typescript-eslint/types@8.72.1': {} 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 dependencies: 4408 dependencies:
4516− '@typescript-eslint/project-service': 8.72.1(typescript@7.0.2) 4409+ '@typescript-eslint/project-service': 8.72.1(typescript@5.9.3)
4517− '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@7.0.2) 4410+ '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@5.9.3)
4518 '@typescript-eslint/types': 8.72.1 4411 '@typescript-eslint/types': 8.72.1
4519 '@typescript-eslint/visitor-keys': 8.72.1 4412 '@typescript-eslint/visitor-keys': 8.72.1
4520 debug: 4.4.3 4413 debug: 4.4.3
4521 minimatch: 10.2.6 4414 minimatch: 10.2.6
4522 semver: 7.8.5 4415 semver: 7.8.5
4523 tinyglobby: 0.2.17 4416 tinyglobby: 0.2.17
4524− ts-api-utils: 2.5.0(typescript@7.0.2) 4417+ ts-api-utils: 2.5.0(typescript@5.9.3)
4525− typescript: 7.0.2 4418+ typescript: 5.9.3
4526 transitivePeerDependencies: 4419 transitivePeerDependencies:
4527 - supports-color 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 dependencies: 4423 dependencies:
4531 '@eslint-community/eslint-utils': 4.10.1(eslint@10.14.0(jiti@2.7.0)) 4424 '@eslint-community/eslint-utils': 4.10.1(eslint@10.14.0(jiti@2.7.0))
4532 '@typescript-eslint/scope-manager': 8.72.1 4425 '@typescript-eslint/scope-manager': 8.72.1
4533 '@typescript-eslint/types': 8.72.1 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 eslint: 10.14.0(jiti@2.7.0) 4428 eslint: 10.14.0(jiti@2.7.0)
4536− typescript: 7.0.2 4429+ typescript: 5.9.3
4537 transitivePeerDependencies: 4430 transitivePeerDependencies:
4538 - supports-color 4431 - supports-color
4539 4432
@@ -4542,66 +4435,6 @@ snapshots:
4542 '@typescript-eslint/types': 8.72.1 4435 '@typescript-eslint/types': 8.72.1
4543 eslint-visitor-keys: 5.0.1 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 '@ungap/structured-clone@1.4.0': {} 4438 '@ungap/structured-clone@1.4.0': {}
4606 4439
4607 '@unrs/resolver-binding-android-arm-eabi@1.12.2': 4440 '@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -5335,20 +5168,20 @@ snapshots:
5335 5168
5336 escape-string-regexp@5.0.0: {} 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 dependencies: 5172 dependencies:
5340 '@next/eslint-plugin-next': 16.4.3(eslint@10.14.0(jiti@2.7.0)) 5173 '@next/eslint-plugin-next': 16.4.3(eslint@10.14.0(jiti@2.7.0))
5341 eslint: 10.14.0(jiti@2.7.0) 5174 eslint: 10.14.0(jiti@2.7.0)
5342 eslint-import-resolver-node: 0.3.10 5175 eslint-import-resolver-node: 0.3.10
5343 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.14.0(jiti@2.7.0)) 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 eslint-plugin-jsx-a11y: 6.10.2(eslint@10.14.0(jiti@2.7.0)) 5178 eslint-plugin-jsx-a11y: 6.10.2(eslint@10.14.0(jiti@2.7.0))
5346 eslint-plugin-react: 7.37.5(eslint@10.14.0(jiti@2.7.0)) 5179 eslint-plugin-react: 7.37.5(eslint@10.14.0(jiti@2.7.0))
5347 eslint-plugin-react-hooks: 7.1.1(eslint@10.14.0(jiti@2.7.0)) 5180 eslint-plugin-react-hooks: 7.1.1(eslint@10.14.0(jiti@2.7.0))
5348 globals: 16.4.0 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 optionalDependencies: 5183 optionalDependencies:
5351− typescript: 7.0.2 5184+ typescript: 5.9.3
5352 transitivePeerDependencies: 5185 transitivePeerDependencies:
5353 - '@typescript-eslint/parser' 5186 - '@typescript-eslint/parser'
5354 - eslint-import-resolver-webpack 5187 - eslint-import-resolver-webpack
@@ -5374,22 +5207,22 @@ snapshots:
5374 tinyglobby: 0.2.17 5207 tinyglobby: 0.2.17
5375 unrs-resolver: 1.12.2 5208 unrs-resolver: 1.12.2
5376 optionalDependencies: 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 transitivePeerDependencies: 5211 transitivePeerDependencies:
5379 - supports-color 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 dependencies: 5215 dependencies:
5383 debug: 3.2.7 5216 debug: 3.2.7
5384 optionalDependencies: 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 eslint: 10.14.0(jiti@2.7.0) 5219 eslint: 10.14.0(jiti@2.7.0)
5387 eslint-import-resolver-node: 0.3.10 5220 eslint-import-resolver-node: 0.3.10
5388 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.14.0(jiti@2.7.0)) 5221 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.14.0(jiti@2.7.0))
5389 transitivePeerDependencies: 5222 transitivePeerDependencies:
5390 - supports-color 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 dependencies: 5226 dependencies:
5394 '@rtsao/scc': 1.1.0 5227 '@rtsao/scc': 1.1.0
5395 array-includes: 3.2.0 5228 array-includes: 3.2.0
@@ -5400,7 +5233,7 @@ snapshots:
5400 doctrine: 2.1.0 5233 doctrine: 2.1.0
5401 eslint: 10.14.0(jiti@2.7.0) 5234 eslint: 10.14.0(jiti@2.7.0)
5402 eslint-import-resolver-node: 0.3.10 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 hasown: 2.0.4 5237 hasown: 2.0.4
5405 is-core-module: 2.17.0 5238 is-core-module: 2.17.0
5406 is-glob: 4.0.3 5239 is-glob: 4.0.3
@@ -5412,7 +5245,7 @@ snapshots:
5412 string.prototype.trimend: 1.0.10 5245 string.prototype.trimend: 1.0.10
5413 tsconfig-paths: 3.15.0 5246 tsconfig-paths: 3.15.0
5414 optionalDependencies: 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 transitivePeerDependencies: 5249 transitivePeerDependencies:
5417 - eslint-import-resolver-typescript 5250 - eslint-import-resolver-typescript
5418 - eslint-import-resolver-webpack 5251 - eslint-import-resolver-webpack
@@ -6707,6 +6540,12 @@ snapshots:
6707 6540
6708 picomatch@4.0.7: {} 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 pngjs@7.0.0: {} 6549 pngjs@7.0.0: {}
6711 6550
6712 points-on-curve@0.2.0: {} 6551 points-on-curve@0.2.0: {}
@@ -7178,9 +7017,9 @@ snapshots:
7178 7017
7179 ts-algebra@2.0.0: {} 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 dependencies: 7021 dependencies:
7183− typescript: 7.0.2 7022+ typescript: 5.9.3
7184 7023
7185 ts-dedent@2.3.0: {} 7024 ts-dedent@2.3.0: {}
7186 7025
@@ -7235,39 +7074,18 @@ snapshots:
7235 possible-typed-array-names: 1.1.0 7074 possible-typed-array-names: 1.1.0
7236 reflect.getprototypeof: 1.0.10 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 dependencies: 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) 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)
7241− '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2) 7080+ '@typescript-eslint/parser': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
7242− '@typescript-eslint/typescript-estree': 8.72.1(typescript@7.0.2) 7081+ '@typescript-eslint/typescript-estree': 8.72.1(typescript@5.9.3)
7243− '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@7.0.2) 7082+ '@typescript-eslint/utils': 8.72.1(eslint@10.14.0(jiti@2.7.0))(typescript@5.9.3)
7244 eslint: 10.14.0(jiti@2.7.0) 7083 eslint: 10.14.0(jiti@2.7.0)
7245− typescript: 7.0.2 7084+ typescript: 5.9.3
7246 transitivePeerDependencies: 7085 transitivePeerDependencies:
7247 - supports-color 7086 - supports-color
7248 7087
7249− typescript@7.0.2: 7088+ typescript@5.9.3: {}
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
7271 7089
7272 unbox-primitive@1.1.0: 7090 unbox-primitive@1.1.0:
7273 dependencies: 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 for (const prov of ["openai", "anthropic"] as const) if (present[prov].length) 63 for (const prov of ["openai", "anthropic"] as const) if (present[prov].length)
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]})`; 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 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()`; 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 console.log(`registry: ${upserted} models upserted (${fresh} new/untested), openai=${present.openai.length} anthropic=${present.anthropic.length}`); 74 console.log(`registry: ${upserted} models upserted (${fresh} new/untested), openai=${present.openai.length} anthropic=${present.anthropic.length}`);
67 await sql.end(); 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 async function main() { 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 const existing = await sql`SELECT username FROM owner WHERE id = 1`; 34 const existing = await sql`SELECT username FROM owner WHERE id = 1`;
27 if (existing.length) console.log(`Owner already configured (${existing[0].username}). Continuing will RESET the password and revoke all sessions.`); 35 if (existing.length) console.log(`Owner already configured (${existing[0].username}). Continuing will RESET the password and revoke all sessions.`);
28 const username = (await ask(`Username [${existing[0]?.username ?? "simon"}]: `)).trim() || existing[0]?.username || "simon"; 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