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