From 7cd3b0d8cdc69ad7726bae47c39893a83fbdbcb3 Mon Sep 17 00:00:00 2001
From: D4kooo
Date: Wed, 10 Jun 2026 10:25:44 +0200
Subject: [PATCH] feat: onboarding grand public, installation une commande,
Board v2 (v0.2.0)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rend Louis installable et utilisable par des professionnels non-techniciens.
Onboarding
- Assistant de premier lancement /setup : compte admin → clé IA testée avant
enregistrement → prêt. Verrouillé dès le premier compte créé.
- Carte « Prise en main » persistante (sidebar), quick-add provider depuis les
états vides du chat et de la bibliothèque de modèles.
Installation une commande
- Image Docker app (standalone) + image migrate (drizzle-kit push) publiées
sur GHCR à chaque tag v* (release.yml, multi-arch amd64/arm64).
- docker-compose.prod.yml autonome + scripts/install.sh (curl | bash
idempotent : Docker, secrets générés, pull, santé, ouvre /setup).
- npm run health-check : pré-vol (Node, secrets, Postgres+pgvector, Redis,
S3, Gotenberg, disque) avec messages actionnables.
Board v2
- Mode Maestro : routage dynamique des agents (agent-as-tool) — le terminal
choisit qui consulter, peut re-déléguer, puis répond. Preset « Le Bureau ».
- Garde-fous human-in-the-loop : les outils sensibles (edit_document, MCP par
défaut, configurable via LOUIS_APPROVAL_TOOLS) attendent l'approbation de
l'utilisateur en cours de run.
Observabilité des outils
- Instrumentation des appels d'outils (table tool_invocations), page
/settings/tools, bloc de raisonnement repliable, timeline d'outils.
Corrections
- fix(redis) : élimine l'échec systématique de la première commande Redis
après chaque boot (fail-open + warning à tort).
236 tests, build de production OK.
---
.dockerignore | 2 +
.github/workflows/release.yml | 73 +++
Dockerfile | 22 +
README.md | 16 +-
docker-compose.prod.yml | 124 ++++
docs/installation/one-command.md | 70 ++
.../migrations/0011_tool_observability.sql | 25 +
eslint.config.mjs | 40 ++
package-lock.json | 606 +++---------------
package.json | 2 +
scripts/health-check.ts | 216 +++++++
scripts/install.sh | 116 ++++
src/app/(app)/board/[id]/add-agent-dialog.tsx | 8 +
.../board/[id]/execution-order-panel.tsx | 6 +-
src/app/(app)/board/[id]/page.tsx | 34 +-
.../(app)/board/[id]/pipeline-mode-bar.tsx | 47 +-
.../(app)/board/[id]/pipeline-workflow.tsx | 68 +-
src/app/(app)/board/actions.ts | 4 +-
src/app/(app)/board/mode-meta.ts | 12 +-
src/app/(app)/board/page.tsx | 54 +-
src/app/(app)/board/reload-button.tsx | 26 +
src/app/(app)/board/try-pipeline-button.tsx | 2 +
src/app/(app)/chat/approval-card.tsx | 128 ++++
src/app/(app)/chat/chat-shell.tsx | 43 +-
src/app/(app)/chat/page.tsx | 39 +-
src/app/(app)/chat/reasoning-block.tsx | 96 +++
src/app/(app)/chat/tool-timeline.tsx | 64 +-
src/app/(app)/dashboard/page.tsx | 27 +-
src/app/(app)/getting-started.tsx | 146 +++++
src/app/(app)/layout.tsx | 37 +-
src/app/(app)/mobile-nav.tsx | 4 +-
src/app/(app)/settings/connectors/page.tsx | 76 ++-
.../models/library/library-browser.tsx | 7 +-
src/app/(app)/settings/providers/actions.ts | 107 ++++
src/app/(app)/settings/usage/page.tsx | 73 +++
src/app/(app)/sidebar-content.tsx | 8 +
src/app/api/chat/approval/route.ts | 43 ++
src/app/api/chat/route.ts | 29 +
src/app/api/cron/retention/route.ts | 8 +-
src/app/api/ready/route.ts | 5 +-
src/app/globals.css | 86 +++
src/app/login/page.tsx | 4 +
src/app/setup/actions.ts | 103 +++
src/app/setup/page.tsx | 64 ++
src/app/setup/setup-wizard.tsx | 302 +++++++++
src/components/provider-key-form.tsx | 205 ++++++
src/components/provider-quick-add.tsx | 60 ++
src/db/schema/index.ts | 1 +
src/db/schema/messages.ts | 3 +
src/db/schema/observability.ts | 58 ++
src/db/schema/pipelines.ts | 7 +-
src/lib/ai/approval.test.ts | 135 ++++
src/lib/ai/approval.ts | 155 +++++
src/lib/ai/saved-parts.ts | 4 +
src/lib/observability/query.ts | 76 +++
src/lib/observability/tools.ts | 146 +++++
src/lib/orchestrator/agents/base.ts | 30 +-
src/lib/orchestrator/agents/default.ts | 33 +-
src/lib/orchestrator/cost-estimate.ts | 5 +
.../orchestrator/orchestrator-maestro.test.ts | 291 +++++++++
src/lib/orchestrator/orchestrator.ts | 211 ++++++
src/lib/orchestrator/presets.ts | 34 +
src/lib/orchestrator/tool-catalogue.ts | 95 +++
src/lib/orchestrator/types.ts | 33 +-
src/lib/rate-limit.test.ts | 22 +-
src/lib/rate-limit.ts | 7 +-
src/lib/redis.ts | 32 +
src/lib/setup/status.ts | 17 +
tests/e2e/setup.spec.ts | 96 +++
69 files changed, 4151 insertions(+), 677 deletions(-)
create mode 100644 docker-compose.prod.yml
create mode 100644 docs/installation/one-command.md
create mode 100644 drizzle/migrations/0011_tool_observability.sql
create mode 100644 scripts/health-check.ts
create mode 100755 scripts/install.sh
create mode 100644 src/app/(app)/board/reload-button.tsx
create mode 100644 src/app/(app)/chat/approval-card.tsx
create mode 100644 src/app/(app)/chat/reasoning-block.tsx
create mode 100644 src/app/(app)/getting-started.tsx
create mode 100644 src/app/api/chat/approval/route.ts
create mode 100644 src/app/setup/actions.ts
create mode 100644 src/app/setup/page.tsx
create mode 100644 src/app/setup/setup-wizard.tsx
create mode 100644 src/components/provider-key-form.tsx
create mode 100644 src/components/provider-quick-add.tsx
create mode 100644 src/db/schema/observability.ts
create mode 100644 src/lib/ai/approval.test.ts
create mode 100644 src/lib/ai/approval.ts
create mode 100644 src/lib/observability/query.ts
create mode 100644 src/lib/observability/tools.ts
create mode 100644 src/lib/orchestrator/orchestrator-maestro.test.ts
create mode 100644 src/lib/orchestrator/tool-catalogue.ts
create mode 100644 src/lib/setup/status.ts
create mode 100644 tests/e2e/setup.spec.ts
diff --git a/.dockerignore b/.dockerignore
index baa9295..222798c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -10,6 +10,8 @@ node_modules
coverage
test-results
playwright-report
+graphify-out
+*.pdf
blob-report
Dockerfile*
docker-compose*.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 351215d..d47c092 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,6 +15,7 @@ on:
permissions:
contents: write
+ packages: write
jobs:
release:
@@ -80,3 +81,75 @@ jobs:
la chaîne d'approvisionnement et corréler aux CVE publiées.
## Notes de release
+
+ Images Docker publiées sur GHCR pour cette release :
+
+ ghcr.io/association-dataring/louis:
+ ghcr.io/association-dataring/louis-migrate:
+
+ # Publication des images Docker sur GHCR : l'image app (standalone) et
+ # l'image migrate (drizzle-kit push one-shot) consommées par
+ # docker-compose.prod.yml et l'installeur scripts/install.sh.
+ docker:
+ name: Build and push Docker images (GHCR)
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU (arm64)
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Image metadata (app)
+ id: meta_app
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=raw,value=latest
+
+ - name: Image metadata (migrate)
+ id: meta_migrate
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository }}-migrate
+ tags: |
+ type=semver,pattern={{version}}
+ type=raw,value=latest
+
+ - name: Build and push app image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ target: runner
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta_app.outputs.tags }}
+ labels: ${{ steps.meta_app.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build and push migrate image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ target: migrator
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta_migrate.outputs.tags }}
+ labels: ${{ steps.meta_migrate.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/Dockerfile b/Dockerfile
index acb7689..2095cca 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -38,6 +38,28 @@ ENV ENCRYPTION_KEY=build_placeholder_key_32_chars_long_for_aes256_scrypt_test
RUN npm run build
+# ─────────────────────────────────────────────────────────────────────────────
+# Migrator — image one-shot qui applique le schéma sur la base AVANT le
+# démarrage de l'app (service `migrate` du docker-compose.prod.yml).
+#
+# Pourquoi une image séparée : le runner standalone ne contient ni drizzle-kit
+# ni les sources du schéma (c'est ce qui le maintient à ~250 MB). Le schéma
+# Louis s'applique par `drizzle-kit push` (déclaratif, idempotent) — `--force`
+# car le conteneur n'a pas de TTY pour confirmer interactivement.
+#
+# Build : docker build --target migrator -t louis-migrate .
+# Run : docker run --rm -e DATABASE_URL=… louis-migrate
+# ─────────────────────────────────────────────────────────────────────────────
+FROM node:24-alpine AS migrator
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY package.json tsconfig.json drizzle.config.ts ./
+COPY src/db ./src/db
+COPY scripts/setup-db.ts ./scripts/setup-db.ts
+
+ENV NODE_ENV=production
+CMD ["sh", "-c", "npx tsx scripts/setup-db.ts && npx drizzle-kit push --force"]
+
# ─────────────────────────────────────────────────────────────────────────────
FROM node:24-alpine AS runner
WORKDIR /app
diff --git a/README.md b/README.md
index 31b25e0..09df66c 100644
--- a/README.md
+++ b/README.md
@@ -247,7 +247,21 @@ et [`docs/architecture/data-model.md`](./docs/architecture/data-model.md).
> Pour les autres modèles (Anthropic, OpenAI, Scaleway, OVH, Albert),
> les clés sont **optionnelles** et configurables une fois Louis lancé.
-### Installation
+### Installation en une commande (recommandée)
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/Association-DataRing/Louis/main/scripts/install.sh | bash
+```
+
+Images pré-buildées (GHCR), secrets générés automatiquement, schéma appliqué
+au démarrage — puis l'**assistant de premier lancement** (`/setup`) guide la
+création du compte admin et la première clé IA dans le navigateur. Détails et
+mise à jour : [docs/installation/one-command.md](./docs/installation/one-command.md).
+
+> Seul prérequis : [Docker](https://docs.docker.com/get-docker/) (Compose v2).
+> Node.js n'est nécessaire que pour l'installation depuis les sources ci-dessous.
+
+### Installation depuis les sources (développement)
**1. Cloner et préparer les secrets**
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..81fc392
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,124 @@
+# ─────────────────────────────────────────────────────────────────────────────
+# Louis — stack de production autonome
+#
+# Consommé par l'installeur une commande (scripts/install.sh), utilisable
+# aussi directement :
+#
+# 1. créer un fichier .env à côté de ce fichier (secrets — voir ci-dessous)
+# 2. docker compose -f docker-compose.prod.yml up -d
+# 3. ouvrir http://localhost:3000 → l'assistant /setup prend le relais
+#
+# Variables .env attendues (générées automatiquement par install.sh) :
+# POSTGRES_PASSWORD=… mot de passe Postgres interne
+# AUTH_SECRET=… openssl rand -base64 32
+# ENCRYPTION_KEY=… openssl rand -base64 32 (chiffrement des clés)
+# S3_SECRET_ACCESS_KEY=… mot de passe MinIO interne
+# Optionnelles :
+# LOUIS_VERSION=v0.1.0 tag d'image (défaut : latest)
+# LOUIS_IMAGE=ghcr.io/… registre alternatif (fork, miroir interne)
+# LOUIS_PORT=3000 port d'écoute sur l'hôte
+#
+# Mise à jour :
+# docker compose -f docker-compose.prod.yml pull
+# docker compose -f docker-compose.prod.yml up -d
+# (le service `migrate` ré-applique le schéma avant le redémarrage de l'app)
+#
+# Seul le port de l'app est exposé sur l'hôte — Postgres, Redis, MinIO et
+# Gotenberg restent sur le réseau interne du compose. Mettre un reverse
+# proxy TLS (Caddy/Nginx/Traefik) devant pour un accès distant.
+# ─────────────────────────────────────────────────────────────────────────────
+
+services:
+ postgres:
+ image: pgvector/pgvector:pg16
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: louis
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?définir POSTGRES_PASSWORD dans .env}
+ POSTGRES_DB: louis
+ volumes:
+ - louis-postgres:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U louis -d louis"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ volumes:
+ - louis-redis:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ minio:
+ image: minio/minio:RELEASE.2025-09-07T16-13-09Z
+ restart: unless-stopped
+ command: server /data
+ environment:
+ MINIO_ROOT_USER: louis
+ MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:?définir S3_SECRET_ACCESS_KEY dans .env}
+ volumes:
+ - louis-minio:/data
+ healthcheck:
+ test: ["CMD", "mc", "ready", "local"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ # LibreOffice headless pour le rendu fidèle DOCX → PDF du DocPanel.
+ gotenberg:
+ image: gotenberg/gotenberg:8
+ restart: unless-stopped
+ command:
+ - "gotenberg"
+ - "--api-port=3000"
+ - "--api-timeout=60s"
+
+ # One-shot : applique le schéma (pgvector + drizzle-kit push) puis sort.
+ # L'app ne démarre qu'après sa réussite (service_completed_successfully).
+ migrate:
+ image: ${LOUIS_IMAGE:-ghcr.io/association-dataring/louis}-migrate:${LOUIS_VERSION:-latest}
+ restart: "no"
+ environment:
+ DATABASE_URL: postgresql://louis:${POSTGRES_PASSWORD}@postgres:5432/louis
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ app:
+ image: ${LOUIS_IMAGE:-ghcr.io/association-dataring/louis}:${LOUIS_VERSION:-latest}
+ restart: unless-stopped
+ ports:
+ - "${LOUIS_PORT:-3000}:3000"
+ environment:
+ NODE_ENV: production
+ DATABASE_URL: postgresql://louis:${POSTGRES_PASSWORD}@postgres:5432/louis
+ REDIS_URL: redis://redis:6379
+ S3_ENDPOINT: http://minio:9000
+ S3_REGION: eu-west-3
+ S3_BUCKET: louis
+ S3_ACCESS_KEY_ID: louis
+ S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
+ # MinIO exige le path-style (la détection automatique ne couvre que
+ # les endpoints localhost, pas le hostname interne `minio`).
+ S3_FORCE_PATH_STYLE: "true"
+ GOTENBERG_URL: http://gotenberg:3000
+ AUTH_SECRET: ${AUTH_SECRET:?définir AUTH_SECRET dans .env}
+ ENCRYPTION_KEY: ${ENCRYPTION_KEY:?définir ENCRYPTION_KEY dans .env}
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ redis:
+ condition: service_healthy
+ minio:
+ condition: service_healthy
+
+volumes:
+ louis-postgres:
+ louis-redis:
+ louis-minio:
diff --git a/docs/installation/one-command.md b/docs/installation/one-command.md
new file mode 100644
index 0000000..3af62df
--- /dev/null
+++ b/docs/installation/one-command.md
@@ -0,0 +1,70 @@
+# Installation en une commande
+
+La façon la plus simple d'installer Louis sur une machine (poste de cabinet,
+serveur, VPS). Une seule dépendance : [Docker](https://docs.docker.com/get-docker/)
+avec Compose v2.
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/Association-DataRing/Louis/main/scripts/install.sh | bash
+```
+
+Le script :
+
+1. vérifie que Docker tourne ;
+2. crée un dossier `./louis` contenant le `docker-compose.prod.yml` et un
+ fichier `.env` avec des **secrets générés aléatoirement** (jamais écrasés
+ s'ils existent — relancer le script est sans danger) ;
+3. télécharge les images publiées sur GHCR (app pré-buildée + migrateur de
+ schéma) et démarre les cinq services : Louis, PostgreSQL + pgvector,
+ Redis, MinIO, Gotenberg ;
+4. applique le schéma de base automatiquement (service `migrate`, one-shot) ;
+5. ouvre `http://localhost:3000` — **l'assistant de premier lancement**
+ prend le relais : compte administrateur, première clé IA (testée avant
+ enregistrement), et la première conversation est à un clic.
+
+Aucun terminal n'est nécessaire après cette commande.
+
+## Variables optionnelles
+
+| Variable | Défaut | Rôle |
+|---|---|---|
+| `LOUIS_DIR` | `./louis` | dossier d'installation |
+| `LOUIS_VERSION` | `latest` | tag d'image (ex. `v0.2.0`) |
+| `LOUIS_PORT` | `3000` | port HTTP local |
+| `LOUIS_REPO_RAW` | repo officiel | base raw GitHub (fork, miroir interne) |
+
+```bash
+LOUIS_PORT=8080 LOUIS_VERSION=v0.2.0 \
+ curl -fsSL https://raw.githubusercontent.com/Association-DataRing/Louis/main/scripts/install.sh | bash
+```
+
+## Mise à jour
+
+```bash
+cd louis
+docker compose -f docker-compose.prod.yml pull
+docker compose -f docker-compose.prod.yml up -d
+```
+
+Le service `migrate` ré-applique le schéma (idempotent) avant le redémarrage
+de l'app — pas d'étape manuelle.
+
+## Sauvegarde
+
+Le fichier `louis/.env` contient `ENCRYPTION_KEY`, qui chiffre les clés API
+stockées : **sa perte rend ces clés irrécupérables**. Sauvegardez-le avec vos
+données (cf. [Sauvegarde et restauration](../admin/backups.md) pour la base).
+
+## Accès distant
+
+Seul le port de l'app est exposé. Pour servir Louis en HTTPS sur un domaine,
+placez un reverse proxy TLS devant (Caddy, Nginx, Traefik) — voir les
+exemples de la page [bare-metal](./bare-metal.md).
+
+## Désinstallation
+
+```bash
+cd louis
+docker compose -f docker-compose.prod.yml down # stop
+docker compose -f docker-compose.prod.yml down -v # stop + données (irréversible)
+```
diff --git a/drizzle/migrations/0011_tool_observability.sql b/drizzle/migrations/0011_tool_observability.sql
new file mode 100644
index 0000000..f5a6cf6
--- /dev/null
+++ b/drizzle/migrations/0011_tool_observability.sql
@@ -0,0 +1,25 @@
+-- Télémétrie d'exécution des outils (connecteurs + MCP) : latence et
+-- succès/échec PAR appel. Distinct de l'audit de conformité (audit_log) ; ici
+-- on répond à « quel outil rame ou échoue, et à quelle fréquence ». Alimente
+-- la section « Fiabilité des outils » de /settings/usage. Enregistrement
+-- best-effort, scopé par utilisateur (null = contexte système).
+-- Cf. lib/observability/tools.ts et lib/observability/query.ts.
+
+CREATE TABLE IF NOT EXISTS "tool_invocations" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
+ "tool_name" text NOT NULL,
+ "category" text NOT NULL,
+ "success" boolean NOT NULL,
+ "error_reason" text,
+ "duration_ms" integer NOT NULL,
+ "created_at" timestamp NOT NULL DEFAULT now()
+);
+
+-- Agrégats « par outil sur la période » (page usage).
+CREATE INDEX IF NOT EXISTS "tool_invocations_name_created_idx"
+ ON "tool_invocations" ("tool_name", "created_at");
+
+-- Filtre « appels récents » + nettoyage par rétention.
+CREATE INDEX IF NOT EXISTS "tool_invocations_created_idx"
+ ON "tool_invocations" ("created_at");
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 199c0ec..40d74d9 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -11,6 +11,46 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
]),
+ // ─── Ratchets structurels ────────────────────────────────────────────────
+ // Garde-fous de dette : non-bloquants (warn) pour ne pas casser le build
+ // (Next 16 ne lance pas ESLint au build, et `npm run lint` n'impose pas
+ // --max-warnings), mais rendent visible toute dérive. Inspiré des ratchets
+ // de vLLM Studio (max-lines + frontières d'import), ramenés au minimum sans
+ // dépendance ESLint supplémentaire.
+ {
+ files: ["src/**/*.{ts,tsx}"],
+ ignores: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
+ rules: {
+ // Au-delà de ~500 lignes, un fichier mélange en général plusieurs
+ // responsabilités. 4 fichiers dépassent déjà ce seuil (chat-shell,
+ // orchestrator, route chat, connectors/tools) — l'avertissement crée la
+ // pression d'extraction sans imposer un refactor immédiat.
+ "max-lines": [
+ "warn",
+ { max: 500, skipBlankLines: true, skipComments: true },
+ ],
+ },
+ },
+ // Frontière d'architecture : le cœur (`src/lib`) ne doit JAMAIS dépendre de
+ // la couche UI/route (`src/app`) — la dépendance va dans l'autre sens. Aucune
+ // violation existante, donc imposé en erreur pour verrouiller l'invariant.
+ {
+ files: ["src/lib/**/*.{ts,tsx}"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["@/app/*", "@/app/**", "**/app/**"],
+ message:
+ "src/lib (cœur) ne doit pas importer src/app (UI/route). Inversez la dépendance : exposez la logique depuis lib et consommez-la depuis app.",
+ },
+ ],
+ },
+ ],
+ },
+ },
]);
export default eslintConfig;
diff --git a/package-lock.json b/package-lock.json
index fb29733..c167d7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,6 +50,7 @@
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
+ "rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
@@ -11578,7 +11579,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -11952,6 +11952,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -11979,6 +11992,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -12019,6 +12048,15 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/hono": {
"version": "4.12.21",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
@@ -13543,6 +13581,21 @@
"underscore": "^1.13.1"
}
},
+ "node_modules/lowlight": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
+ "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.0.0",
+ "highlight.js": "~11.11.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -16501,6 +16554,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/rehype-highlight": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
+ "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-to-text": "^4.0.0",
+ "lowlight": "^3.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -18805,6 +18875,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
@@ -19172,478 +19256,10 @@
}
}
},
- "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
- "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/android-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
- "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/android-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
- "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/android-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
- "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
- "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
- "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
- "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
- "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
- "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
- "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
- "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
- "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
- "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
- "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
- "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
- "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
- "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
- "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
- "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
- "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
- "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
- "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
- "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/win32-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
- "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@vitest/mocker": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
- "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+ "node_modules/vitest/node_modules/@vitest/mocker": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
+ "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -19667,50 +19283,6 @@
}
}
},
- "node_modules/vitest/node_modules/esbuild": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
- "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.28.0",
- "@esbuild/android-arm": "0.28.0",
- "@esbuild/android-arm64": "0.28.0",
- "@esbuild/android-x64": "0.28.0",
- "@esbuild/darwin-arm64": "0.28.0",
- "@esbuild/darwin-x64": "0.28.0",
- "@esbuild/freebsd-arm64": "0.28.0",
- "@esbuild/freebsd-x64": "0.28.0",
- "@esbuild/linux-arm": "0.28.0",
- "@esbuild/linux-arm64": "0.28.0",
- "@esbuild/linux-ia32": "0.28.0",
- "@esbuild/linux-loong64": "0.28.0",
- "@esbuild/linux-mips64el": "0.28.0",
- "@esbuild/linux-ppc64": "0.28.0",
- "@esbuild/linux-riscv64": "0.28.0",
- "@esbuild/linux-s390x": "0.28.0",
- "@esbuild/linux-x64": "0.28.0",
- "@esbuild/netbsd-arm64": "0.28.0",
- "@esbuild/netbsd-x64": "0.28.0",
- "@esbuild/openbsd-arm64": "0.28.0",
- "@esbuild/openbsd-x64": "0.28.0",
- "@esbuild/openharmony-arm64": "0.28.0",
- "@esbuild/sunos-x64": "0.28.0",
- "@esbuild/win32-arm64": "0.28.0",
- "@esbuild/win32-ia32": "0.28.0",
- "@esbuild/win32-x64": "0.28.0"
- }
- },
"node_modules/vitest/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
diff --git a/package.json b/package.json
index f760a50..8d40231 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:setup": "tsx scripts/setup-db.ts && drizzle-kit push",
+ "health-check": "tsx scripts/health-check.ts",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx scripts/seed.ts",
"demo": "tsx scripts/seed-demo.ts",
@@ -65,6 +66,7 @@
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
+ "rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
diff --git a/scripts/health-check.ts b/scripts/health-check.ts
new file mode 100644
index 0000000..dc68d87
--- /dev/null
+++ b/scripts/health-check.ts
@@ -0,0 +1,216 @@
+/**
+ * Pré-vol avant installation ou mise à jour : valide l'environnement complet
+ * en une commande, avec des messages actionnables (pensé pour un opérateur
+ * non-développeur — hébergeur partenaire, IT de cabinet).
+ *
+ * npm run health-check
+ *
+ * Vérifie : version Node, secrets requis (.env), Postgres + extension
+ * pgvector, Redis, stockage S3/MinIO, Gotenberg (optionnel), espace disque.
+ * Code de sortie ≠ 0 si au moins un check bloquant échoue.
+ */
+import "dotenv/config";
+import { execFileSync } from "node:child_process";
+import { sql } from "drizzle-orm";
+
+type Check = {
+ name: string;
+ /** false = avertissement seulement (n'affecte pas le code de sortie). */
+ blocking: boolean;
+ run: () => Promise;
+};
+
+const ok = (msg: string) => `\x1b[32m✓\x1b[0m ${msg}`;
+const ko = (msg: string) => `\x1b[31m✗\x1b[0m ${msg}`;
+const warn = (msg: string) => `\x1b[33m⚠\x1b[0m ${msg}`;
+
+const checks: Check[] = [
+ {
+ name: "Node.js",
+ blocking: true,
+ run: async () => {
+ const major = Number(process.versions.node.split(".")[0]);
+ if (major < 24) {
+ throw new Error(
+ `Node ${process.versions.node} détecté — Louis requiert Node 24+. Installez la LTS : https://nodejs.org`
+ );
+ }
+ return `Node ${process.versions.node}`;
+ },
+ },
+ {
+ name: "Secrets",
+ blocking: true,
+ run: async () => {
+ const missing = ["DATABASE_URL", "AUTH_SECRET", "ENCRYPTION_KEY"].filter(
+ (k) => !process.env[k]
+ );
+ if (missing.length > 0) {
+ throw new Error(
+ `Variable(s) manquante(s) dans .env : ${missing.join(", ")}. Générez les secrets avec : openssl rand -base64 32`
+ );
+ }
+ if ((process.env.AUTH_SECRET ?? "").length < 32) {
+ throw new Error(
+ "AUTH_SECRET trop court (< 32 caractères) — régénérez avec : openssl rand -base64 32"
+ );
+ }
+ return "DATABASE_URL, AUTH_SECRET, ENCRYPTION_KEY présents";
+ },
+ },
+ {
+ name: "PostgreSQL",
+ blocking: true,
+ run: async () => {
+ const { db } = await import("../src/db");
+ await db.execute(sql`SELECT 1`);
+ const rows = await db.execute(
+ sql`SELECT extname FROM pg_extension WHERE extname = 'vector'`
+ );
+ const hasVector = (rows as unknown as { length?: number }).length !== 0;
+ if (!hasVector) {
+ throw new Error(
+ "Postgres répond mais l'extension pgvector est absente — lancez : npm run db:setup"
+ );
+ }
+ return "connexion OK, extension pgvector active";
+ },
+ },
+ {
+ name: "Redis",
+ blocking: true,
+ run: async () => {
+ const { getRedis } = await import("../src/lib/redis");
+ const r = getRedis();
+ // getRedis() lance la connexion (lazyConnect) — on attend l'état
+ // ready avant d'émettre la commande (offline queue désactivée).
+ if (r.status !== "ready") {
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `connexion impossible en 5 s (${process.env.REDIS_URL ?? "redis://localhost:6379"}) — vérifiez que le conteneur Redis tourne (docker compose ps).`
+ )
+ ),
+ 5000
+ );
+ r.once("ready", () => {
+ clearTimeout(t);
+ resolve();
+ });
+ r.once("error", (e) => {
+ clearTimeout(t);
+ reject(
+ new Error(
+ `injoignable (${e instanceof Error ? e.message : String(e)}) — vérifiez REDIS_URL et que le conteneur Redis tourne.`
+ )
+ );
+ });
+ });
+ }
+ const pong = await r.ping();
+ if (pong !== "PONG") throw new Error(`réponse inattendue : ${pong}`);
+ return "PING → PONG";
+ },
+ },
+ {
+ name: "Stockage S3",
+ blocking: true,
+ run: async () => {
+ const endpoint = process.env.S3_ENDPOINT;
+ if (!endpoint) {
+ throw new Error(
+ "S3_ENDPOINT absent du .env — l'upload de documents échouera. MinIO local : http://localhost:9000"
+ );
+ }
+ const res = await fetch(`${endpoint.replace(/\/$/, "")}/minio/health/live`, {
+ signal: AbortSignal.timeout(5000),
+ }).catch(() => null);
+ // Hors MinIO (Scaleway/OVH/AWS), l'endpoint santé n'existe pas : on
+ // tente alors un simple HEAD sur la racine (toute réponse HTTP suffit
+ // à prouver que l'endpoint est joignable).
+ if (res?.ok) return `MinIO joignable (${endpoint})`;
+ const head = await fetch(endpoint, {
+ method: "HEAD",
+ signal: AbortSignal.timeout(5000),
+ }).catch(() => null);
+ if (!head) {
+ throw new Error(
+ `${endpoint} injoignable — vérifiez que le conteneur MinIO tourne (docker compose ps) ou l'URL de votre stockage objet.`
+ );
+ }
+ return `endpoint joignable (${endpoint})`;
+ },
+ },
+ {
+ name: "Gotenberg",
+ blocking: false,
+ run: async () => {
+ const url = process.env.GOTENBERG_URL;
+ if (!url) {
+ throw new Error(
+ "GOTENBERG_URL absent — l'aperçu PDF fidèle des DOCX sera dégradé (fallback mammoth). Optionnel."
+ );
+ }
+ const res = await fetch(`${url.replace(/\/$/, "")}/health`, {
+ signal: AbortSignal.timeout(5000),
+ }).catch(() => null);
+ if (!res?.ok) {
+ throw new Error(
+ `${url} injoignable — l'aperçu PDF fidèle des DOCX sera dégradé. Optionnel.`
+ );
+ }
+ return `joignable (${url})`;
+ },
+ },
+ {
+ name: "Espace disque",
+ blocking: false,
+ run: async () => {
+ try {
+ const out = execFileSync("df", ["-Pk", "."], { encoding: "utf-8" });
+ const freeKb = Number(out.trim().split("\n").at(-1)?.split(/\s+/)[3]);
+ const freeGb = freeKb / 1024 / 1024;
+ if (freeGb < 1) {
+ throw new Error(
+ `${freeGb.toFixed(1)} Go libres — moins de 1 Go, les uploads et les images Docker risquent d'échouer.`
+ );
+ }
+ return `${freeGb.toFixed(1)} Go libres`;
+ } catch (err) {
+ if (err instanceof Error && err.message.includes("Go libres")) throw err;
+ throw new Error("mesure impossible (df indisponible). Optionnel.");
+ }
+ },
+ },
+];
+
+async function main() {
+ console.log("Louis — pré-vol d'installation / mise à jour\n");
+ let blockingFailures = 0;
+
+ for (const check of checks) {
+ try {
+ const detail = await check.run();
+ console.log(`${ok(check.name)} — ${detail}`);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ if (check.blocking) {
+ blockingFailures++;
+ console.log(`${ko(check.name)} — ${msg}`);
+ } else {
+ console.log(`${warn(check.name)} — ${msg}`);
+ }
+ }
+ }
+
+ console.log(
+ blockingFailures === 0
+ ? "\n\x1b[32mPrêt.\x1b[0m Tous les checks bloquants passent."
+ : `\n\x1b[31m${blockingFailures} check(s) bloquant(s) en échec.\x1b[0m Corrigez avant d'installer ou de mettre à jour.`
+ );
+ process.exit(blockingFailures === 0 ? 0 : 1);
+}
+
+main();
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..7e49edf
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────────────────────
+# Louis — installeur une commande
+#
+# curl -fsSL https://raw.githubusercontent.com/Association-DataRing/Louis/main/scripts/install.sh | bash
+#
+# Ce script :
+# 1. vérifie que Docker (+ plugin compose) est installé et démarré ;
+# 2. crée un dossier ./louis avec le docker-compose de production ;
+# 3. génère les secrets (.env) — jamais écrasés s'ils existent déjà ;
+# 4. télécharge les images et démarre la stack (app, Postgres+pgvector,
+# Redis, MinIO, Gotenberg) ;
+# 5. attend que l'app réponde puis ouvre l'assistant de premier lancement.
+#
+# Variables d'environnement optionnelles :
+# LOUIS_DIR=… dossier d'installation (défaut : ./louis)
+# LOUIS_VERSION=… tag d'image, ex. v0.2.0 (défaut : latest)
+# LOUIS_PORT=… port HTTP sur la machine (défaut : 3000)
+# LOUIS_REPO_RAW=… base raw GitHub (fork/miroir)
+#
+# Relancer le script est sans danger : il est idempotent.
+# ─────────────────────────────────────────────────────────────────────────────
+set -euo pipefail
+
+LOUIS_DIR="${LOUIS_DIR:-louis}"
+LOUIS_PORT="${LOUIS_PORT:-3000}"
+LOUIS_REPO_RAW="${LOUIS_REPO_RAW:-https://raw.githubusercontent.com/Association-DataRing/Louis/main}"
+COMPOSE_FILE="docker-compose.prod.yml"
+
+bold() { printf '\033[1m%s\033[0m\n' "$*"; }
+ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
+fail() { printf ' \033[31m✗\033[0m %s\n' "$*" >&2; exit 1; }
+
+rand_secret() {
+ if command -v openssl >/dev/null 2>&1; then
+ openssl rand -base64 32
+ else
+ head -c 32 /dev/urandom | base64
+ fi
+}
+
+bold "Louis — installation"
+
+# 1. Prérequis ────────────────────────────────────────────────────────────────
+command -v docker >/dev/null 2>&1 \
+ || fail "Docker n'est pas installé. Installez Docker Desktop : https://docs.docker.com/get-docker/"
+docker info >/dev/null 2>&1 \
+ || fail "Docker est installé mais ne tourne pas. Démarrez Docker Desktop puis relancez ce script."
+docker compose version >/dev/null 2>&1 \
+ || fail "Le plugin Docker Compose est absent. Mettez Docker à jour (Compose v2 requis)."
+ok "Docker opérationnel"
+
+# 2. Dossier + compose ────────────────────────────────────────────────────────
+mkdir -p "$LOUIS_DIR"
+cd "$LOUIS_DIR"
+
+if [ ! -f "$COMPOSE_FILE" ]; then
+ curl -fsSL "$LOUIS_REPO_RAW/$COMPOSE_FILE" -o "$COMPOSE_FILE" \
+ || fail "Téléchargement de $COMPOSE_FILE impossible depuis $LOUIS_REPO_RAW"
+ ok "$COMPOSE_FILE téléchargé"
+else
+ ok "$COMPOSE_FILE déjà présent (conservé)"
+fi
+
+# 3. Secrets ──────────────────────────────────────────────────────────────────
+# ENCRYPTION_KEY chiffre les clés API stockées : la perdre rend les clés
+# irrécupérables. On ne régénère donc JAMAIS un .env existant.
+if [ ! -f .env ]; then
+ umask 177
+ {
+ echo "# Secrets Louis — générés le $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ echo "# SAUVEGARDEZ CE FICHIER : ENCRYPTION_KEY est irremplaçable."
+ echo "POSTGRES_PASSWORD=$(rand_secret | tr -d '/+=' | head -c 32)"
+ echo "S3_SECRET_ACCESS_KEY=$(rand_secret | tr -d '/+=' | head -c 32)"
+ echo "AUTH_SECRET=$(rand_secret)"
+ echo "ENCRYPTION_KEY=$(rand_secret)"
+ echo "LOUIS_PORT=$LOUIS_PORT"
+ if [ -n "${LOUIS_VERSION:-}" ]; then echo "LOUIS_VERSION=$LOUIS_VERSION"; fi
+ } > .env
+ umask 022
+ ok "Secrets générés dans $LOUIS_DIR/.env (à sauvegarder précieusement)"
+else
+ ok ".env déjà présent (conservé)"
+fi
+
+# 4. Démarrage ────────────────────────────────────────────────────────────────
+bold "Téléchargement des images (premier lancement : quelques minutes)…"
+docker compose -f "$COMPOSE_FILE" pull --quiet
+docker compose -f "$COMPOSE_FILE" up -d
+ok "Stack démarrée"
+
+# 5. Attente de l'app ─────────────────────────────────────────────────────────
+printf ' … démarrage de Louis'
+for _ in $(seq 1 60); do
+ if curl -fsS "http://localhost:$LOUIS_PORT/api/health" >/dev/null 2>&1; then
+ printf '\n'
+ ok "Louis répond sur http://localhost:$LOUIS_PORT"
+ bold ""
+ bold "Installation terminée."
+ echo " Ouvrez http://localhost:$LOUIS_PORT — l'assistant de premier"
+ echo " lancement vous guide : compte administrateur, clé IA, et c'est prêt."
+ echo ""
+ echo " Mise à jour : cd $LOUIS_DIR && docker compose -f $COMPOSE_FILE pull && docker compose -f $COMPOSE_FILE up -d"
+ echo " Arrêt : cd $LOUIS_DIR && docker compose -f $COMPOSE_FILE down"
+ # Ouvre le navigateur quand l'environnement le permet (best-effort).
+ if command -v open >/dev/null 2>&1; then open "http://localhost:$LOUIS_PORT" || true
+ elif command -v xdg-open >/dev/null 2>&1; then xdg-open "http://localhost:$LOUIS_PORT" || true
+ fi
+ exit 0
+ fi
+ printf '.'
+ sleep 2
+done
+
+printf '\n'
+fail "Louis ne répond pas après 2 minutes. Diagnostic : cd $LOUIS_DIR && docker compose -f $COMPOSE_FILE logs app migrate"
diff --git a/src/app/(app)/board/[id]/add-agent-dialog.tsx b/src/app/(app)/board/[id]/add-agent-dialog.tsx
index 1d7291b..99301f9 100644
--- a/src/app/(app)/board/[id]/add-agent-dialog.tsx
+++ b/src/app/(app)/board/[id]/add-agent-dialog.tsx
@@ -277,6 +277,14 @@ export function AddAgentDialog({
+ {modelOptions.length === 0 && (
+
+ Choisissez une clé provider ci-dessus pour sélectionner un
+ modèle. Sans clé, l'agent hérite du modèle du chat au moment
+ de l'envoi.
+
+ )}
+
{modelOptions.length > 0 && (
diff --git a/src/app/(app)/board/[id]/execution-order-panel.tsx b/src/app/(app)/board/[id]/execution-order-panel.tsx
index f1a4a12..8d32ac6 100644
--- a/src/app/(app)/board/[id]/execution-order-panel.tsx
+++ b/src/app/(app)/board/[id]/execution-order-panel.tsx
@@ -15,6 +15,10 @@ const MODE_HINT: Record = {
"Conseil : tous les agents délibèrent (N tours). Le dernier agent — le terminal — synthétise et rend la réponse.",
parallel:
"Parallèle : les agents travaillent en même temps sur la question. Le dernier agent — le terminal — agrège et rend la réponse.",
+ iterative:
+ "Itératif : le premier agent reprend ses notes à chaque tour pour creuser les lacunes. Le dernier agent — le terminal — synthétise et rend la réponse.",
+ maestro:
+ "Maestro : le dernier agent dirige l'équipe — il choisit qui consulter, avec quelle consigne, dans l'ordre qu'il juge utile, puis rend la réponse. L'ordre ci-dessous n'est qu'indicatif.",
};
/**
@@ -32,7 +36,7 @@ export function ExecutionOrderPanel({
}: {
pipelineId: string;
agents: PipelineAgent[];
- mode: "sequential" | "council" | "parallel" | "iterative";
+ mode: "sequential" | "council" | "parallel" | "iterative" | "maestro";
editable: boolean;
}) {
const router = useRouter();
diff --git a/src/app/(app)/board/[id]/page.tsx b/src/app/(app)/board/[id]/page.tsx
index bec962d..72168c2 100644
--- a/src/app/(app)/board/[id]/page.tsx
+++ b/src/app/(app)/board/[id]/page.tsx
@@ -127,7 +127,20 @@ export default async function PipelineEditorPage({
-
+ {!data.pipeline.isPreset && (
+
+
+ Organigramme
+
+
+
+ )}
+
+
{/* Desktop : canvas React Flow. */}
- {!data.pipeline.isPreset && (
-
-
-
- )}
@@ -169,8 +173,14 @@ export default async function PipelineEditorPage({
Cliquez un agent pour l'éditer
- {data.pipeline.mode === "sequential" && !data.pipeline.isPreset && (
- · Glissez les cartes pour réordonner
+ {!data.pipeline.isPreset && data.agents.length > 1 && (
+ data.pipeline.mode === "sequential" ? (
+ · Glissez les cartes pour réordonner
+ ) : (
+
+ · L'ordre se règle dans « Ordre d'exécution » ci-dessous
+
+ )
)}
{data.pipeline.mode === "council" && (
diff --git a/src/app/(app)/board/[id]/pipeline-mode-bar.tsx b/src/app/(app)/board/[id]/pipeline-mode-bar.tsx
index 57c4de2..52edf3d 100644
--- a/src/app/(app)/board/[id]/pipeline-mode-bar.tsx
+++ b/src/app/(app)/board/[id]/pipeline-mode-bar.tsx
@@ -44,6 +44,7 @@ export function PipelineModeBar({ pipeline, agentCount }: PipelineModeBarProps)
"council",
"parallel",
"iterative",
+ "maestro",
];
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
@@ -97,7 +98,7 @@ export function PipelineModeBar({ pipeline, agentCount }: PipelineModeBarProps)
{modes.map((m, i) => {
const meta = MODE_META[m];
@@ -153,24 +154,32 @@ export function PipelineModeBar({ pipeline, agentCount }: PipelineModeBarProps)
})}
@@ -150,9 +165,12 @@ function EmptyState() {
Pas encore de pipeline.
- Les pipelines presets sont semés au premier chargement. Rechargez la
- page si rien n'apparaît.
+ Les pipelines presets sont semés au premier chargement. Si rien
+ n'apparaît, rechargez pour relancer la génération.
+
+
+
);
}
diff --git a/src/app/(app)/board/reload-button.tsx b/src/app/(app)/board/reload-button.tsx
new file mode 100644
index 0000000..08eac8e
--- /dev/null
+++ b/src/app/(app)/board/reload-button.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { IconRefresh } from "@tabler/icons-react";
+import { Button } from "@/components/ui/button";
+
+/**
+ * Petit bouton de rechargement pour l'état vide du board. Les presets sont
+ * semés à la volée par `listPipelines` ; si malgré tout rien n'apparaît
+ * (course au premier rendu), un refresh suffit en général.
+ */
+export function ReloadButton() {
+ const router = useRouter();
+ return (
+
+ );
+}
diff --git a/src/app/(app)/board/try-pipeline-button.tsx b/src/app/(app)/board/try-pipeline-button.tsx
index f5c3a73..5330371 100644
--- a/src/app/(app)/board/try-pipeline-button.tsx
+++ b/src/app/(app)/board/try-pipeline-button.tsx
@@ -23,6 +23,8 @@ const SAMPLE_PROMPTS: Record = {
"Mon client souhaite résilier unilatéralement un contrat de distribution exclusive de 5 ans après 18 mois. Quels risques ?",
"audit-conformite":
"Évaluez la conformité d'un outil de scoring automatique de candidats pour le recrutement.",
+ "le-bureau":
+ "Un salarié protégé a été licencié sans autorisation de l'inspection du travail. Quels recours, quels délais, et préparez la trame du courrier au client.",
};
interface TryPipelineButtonProps {
diff --git a/src/app/(app)/chat/approval-card.tsx b/src/app/(app)/chat/approval-card.tsx
new file mode 100644
index 0000000..d679315
--- /dev/null
+++ b/src/app/(app)/chat/approval-card.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useState } from "react";
+import { IconCheck, IconShieldQuestion, IconX } from "@tabler/icons-react";
+import { cn } from "@/lib/utils";
+import { toolMeta } from "./tool-meta";
+
+export type ApprovalRequestData = {
+ approvalId: string;
+ toolName: string;
+ input?: unknown;
+};
+
+/**
+ * Carte d'approbation human-in-the-loop : émise en cours de run quand un
+ * outil sensible (édition de document, outil MCP) attend le feu vert de
+ * l'utilisateur. La réponse part vers /api/chat/approval qui débloque
+ * l'exécution suspendue côté serveur ; le résultat de l'outil arrive ensuite
+ * dans le même stream (pill d'outil classique).
+ *
+ * `isLive` : la carte n'est actionnable que pendant le streaming du message —
+ * après coup (timeout, refus, reload), elle n'est qu'un constat.
+ */
+export function ApprovalCard({
+ data,
+ isLive,
+}: {
+ data: ApprovalRequestData;
+ isLive: boolean;
+}) {
+ const [state, setState] = useState<
+ "pending" | "sending" | "approved" | "denied"
+ >("pending");
+ const meta = toolMeta(data.toolName);
+ const inputPreview =
+ data.input && typeof data.input === "object"
+ ? JSON.stringify(data.input, null, 2)
+ : null;
+
+ async function respond(approved: boolean) {
+ setState("sending");
+ try {
+ await fetch("/api/chat/approval", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ approvalId: data.approvalId, approved }),
+ });
+ setState(approved ? "approved" : "denied");
+ } catch {
+ setState("pending");
+ }
+ }
+
+ const resolved = state === "approved" || state === "denied";
+ const actionable = isLive && !resolved;
+
+ return (
+
+ {inputPreview && (
+
+
+ Voir le détail de l'action
+
+
+ {inputPreview}
+
+
+ )}
+ {actionable && (
+
+
+
+
+ Sans réponse sous 5 min, l'action est refusée.
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/(app)/chat/chat-shell.tsx b/src/app/(app)/chat/chat-shell.tsx
index 5a456ce..7c7b347 100644
--- a/src/app/(app)/chat/chat-shell.tsx
+++ b/src/app/(app)/chat/chat-shell.tsx
@@ -25,6 +25,7 @@ import { ComposerMenu } from "./composer-menu";
import { DefaultChatTransport, type UIMessage } from "ai";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
+import rehypeHighlight from "rehype-highlight";
import { LouisLogo } from "@/components/louis-logo";
import { ModuleHelp } from "@/components/module-help";
import { Dropzone, uploadDocument } from "@/components/dropzone";
@@ -32,6 +33,8 @@ import { useSmoothText } from "@/lib/use-smooth-text";
import { useStickToBottom } from "@/lib/use-stick-to-bottom";
import { cn } from "@/lib/utils";
import { ThinkingIndicator } from "./thinking-indicator";
+import { ReasoningBlock } from "./reasoning-block";
+import { ApprovalCard, type ApprovalRequestData } from "./approval-card";
import { AgentStepsWrapper } from "./agent-steps-wrapper";
import {
ToolTimeline,
@@ -144,7 +147,7 @@ type PipelineOption = {
isPreset: boolean;
agentCount: number;
/** Mode d'exécution — pilote l'estimation du nombre d'appels LLM. */
- mode: "sequential" | "council" | "parallel";
+ mode: "sequential" | "council" | "parallel" | "iterative" | "maestro";
/** Tours de débat (mode council). null/1 sinon. */
rounds: number | null;
agents: PipelineAgentOption[];
@@ -869,6 +872,11 @@ const AssistantMarkdownPart = memo(function AssistantMarkdownPart({
return (
{
const lang = (className ?? "").match(/language-(\w+)/)?.[1];
@@ -2133,6 +2141,39 @@ export function ChatShell({
// on skippe ici pour éviter les doublons.
return null;
}
+ if (part.type === "data-approval-request") {
+ // Garde-fou human-in-the-loop : un outil sensible
+ // attend le feu vert. Actionnable uniquement pendant
+ // le streaming (la part n'est pas persistée).
+ const data = (part as { data?: ApprovalRequestData })
+ .data;
+ if (!data?.approvalId) return null;
+ return (
+
+ );
+ }
+ if (part.type === "reasoning") {
+ // Tokens de raisonnement d'un modèle « thinking »
+ // (DeepSeek R1, Magistral, o-series, Claude extended
+ // thinking…). Rendu dans un bloc repliable, jamais
+ // injecté dans la réponse finale.
+ const reasoningText = (part as { text?: string }).text;
+ if (!reasoningText) return null;
+ const reasoningStreaming =
+ isLiveMessage &&
+ (part as { state?: string }).state !== "done";
+ return (
+
+ );
+ }
if (part.type === "text") {
if (isUser) {
const isEditing = editingMessageId === m.id;
diff --git a/src/app/(app)/chat/page.tsx b/src/app/(app)/chat/page.tsx
index 932637b..f7b60fb 100644
--- a/src/app/(app)/chat/page.tsx
+++ b/src/app/(app)/chat/page.tsx
@@ -18,6 +18,7 @@ import { seedPresetsForUser } from "@/lib/orchestrator";
import { listEnabledModels } from "../settings/models/actions";
import { getEnabledSkills } from "../settings/skills/actions";
import type { ProviderType } from "@/lib/providers/catalog";
+import { ProviderQuickAdd } from "@/components/provider-quick-add";
import { ChatShell } from "./chat-shell";
type Search = {
@@ -119,7 +120,12 @@ export default async function ChatPage({
description: p.description,
isPreset: p.isPreset,
agentCount: agents.length,
- mode: (p.mode ?? "sequential") as "sequential" | "council" | "parallel",
+ mode: (p.mode ?? "sequential") as
+ | "sequential"
+ | "council"
+ | "parallel"
+ | "iterative"
+ | "maestro",
rounds: p.rounds ?? null,
agents: agents.map((a) => ({
id: a.id,
@@ -266,20 +272,25 @@ export default async function ChatPage({
function NoProviderState() {
return (
-
-
Conversations
-
-
Aucun provider actif
-
- Ajoutez et activez au moins un provider IA pour commencer à
- discuter.
+
+
+
+ Une clé, et Louis s'éveille.
+
+
+ Louis fonctionne avec vos propres clés API — elles restent chiffrées
+ sur votre instance. Connectez-en une pour lancer votre première
+ conversation.
-
- Configurer les providers
-
+
+
+
+ Voir tous les providers dans les réglages
+
+
);
diff --git a/src/app/(app)/chat/reasoning-block.tsx b/src/app/(app)/chat/reasoning-block.tsx
new file mode 100644
index 0000000..19dc69d
--- /dev/null
+++ b/src/app/(app)/chat/reasoning-block.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useState } from "react";
+import {
+ IconChevronDown,
+ IconBulb,
+ IconLoader2,
+} from "@tabler/icons-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Bloc de raisonnement d'un modèle « thinking » (DeepSeek R1, Magistral,
+ * o-series, Claude extended thinking, QwQ…). L'AI SDK émet ces tokens comme
+ * des parts `reasoning` distinctes du texte de réponse final.
+ *
+ * Repliable, fermé par défaut (le raisonnement est un détail de progression,
+ * pas le signal principal). Pendant le streaming on affiche une ligne
+ * d'aperçu (dernière phrase) avec un léger shimmer pour signaler l'activité ;
+ * une fois terminé, on bascule sur un libellé sobre « Raisonnement ».
+ *
+ * Inspiré de la vue d'activité de vLLM Studio, adapté à l'esthétique Louis.
+ */
+export function ReasoningBlock({
+ text,
+ isStreaming,
+}: {
+ text: string;
+ isStreaming: boolean;
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ const trimmed = text.trim();
+ if (!trimmed) return null;
+
+ // Aperçu : la dernière ligne non vide, tronquée. Donne un aperçu vivant du
+ // fil de pensée en cours sans déplier tout le bloc.
+ const preview = lastLine(trimmed, 120);
+
+ return (
+
+ {
+ e.preventDefault();
+ setExpanded((v) => !v);
+ }}
+ className="flex min-h-7 cursor-pointer list-none items-center gap-2 rounded-md py-1 text-sm text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden"
+ >
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
+ {isStreaming ? "Réflexion en cours" : "Raisonnement"}
+
+ {!expanded && isStreaming && preview && (
+
+ {preview}
+
+ )}
+ {!expanded && !isStreaming && (
+
+ )}
+
+
+
+
+ {trimmed}
+
+
+
+ );
+}
+
+/** Dernière ligne non vide d'un texte, tronquée à `max` caractères. */
+function lastLine(text: string, max: number): string {
+ const lines = text.split("\n");
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const line = lines[i].trim();
+ if (line) {
+ return line.length > max ? `${line.slice(0, max)}…` : line;
+ }
+ }
+ return "";
+}
diff --git a/src/app/(app)/chat/tool-timeline.tsx b/src/app/(app)/chat/tool-timeline.tsx
index 38b5d58..7800f35 100644
--- a/src/app/(app)/chat/tool-timeline.tsx
+++ b/src/app/(app)/chat/tool-timeline.tsx
@@ -1,6 +1,8 @@
"use client";
-import { useState, type ReactNode } from "react";
+import { useMemo, useState, type ReactNode } from "react";
+import hljs from "highlight.js/lib/core";
+import json from "highlight.js/lib/languages/json";
import {
IconSparkles,
IconChevronDown,
@@ -12,6 +14,8 @@ import {
import { cn } from "@/lib/utils";
import { toolMeta, summarizeTools } from "./tool-meta";
+hljs.registerLanguage("json", json);
+
export interface ToolTimelineRow {
id: string;
name: string;
@@ -46,7 +50,10 @@ export function ToolTimeline({
isStreaming: boolean;
renderDetail: (row: ToolTimelineRow) => ReactNode;
}) {
- const [collapsed, setCollapsed] = useState(false);
+ // Replié par défaut : les appels d'outils sont un détail de progression,
+ // pas le signal principal. Le résumé (« 3 outils · 2 recherches ») + la
+ // ligne d'aperçu de l'action en cours suffisent à suivre ; on déplie au clic.
+ const [collapsed, setCollapsed] = useState(true);
const [expanded, setExpanded] = useState>(
() => new Set(rows.filter((r) => r.autoExpand).map((r) => r.id))
);
@@ -54,6 +61,15 @@ export function ToolTimeline({
if (rows.length === 0) return null;
const summary = summarizeTools(rows.map((r) => r.name));
+ // Action en cours/dernière : la dernière ligne pending pendant le stream,
+ // sinon la dernière ligne tout court. Affichée en aperçu quand replié.
+ const pendingRow = isStreaming
+ ? [...rows].reverse().find((r) => r.pending)
+ : undefined;
+ const latestRow = pendingRow ?? rows[rows.length - 1];
+ const preview = latestRow
+ ? [latestRow.label, latestRow.summary].filter(Boolean).join(" · ")
+ : "";
function toggleRow(id: string) {
setExpanded((prev) => {
@@ -74,14 +90,28 @@ export function ToolTimeline({
className="group/h w-full flex items-center gap-2.5 py-1.5 text-left"
>
- {summary}
+ {summary}
+ {/* Aperçu de l'action en cours (façon vLLM Studio) — visible quand
+ replié, avec shimmer tant qu'un outil est en cours. */}
+ {collapsed && preview && (
+
+ {preview}
+
+ )}
-
+
{isStreaming ? (
) : durationMs && durationMs > 0 ? (
@@ -168,6 +198,15 @@ export function JsonDetail({
null,
2
);
+ // Coloration JSON (cohérente avec rehype-highlight côté markdown). On
+ // mémoïse le rendu HTML pour ne pas re-highlighter à chaque render.
+ const highlighted = useMemo(() => {
+ try {
+ return hljs.highlight(payload, { language: "json" }).value;
+ } catch {
+ return null;
+ }
+ }, [payload]);
async function copy() {
try {
@@ -198,9 +237,18 @@ export function JsonDetail({
)}
-
- {payload}
-
+ {highlighted ? (
+
+
+
+ ) : (
+
+ {payload}
+
+ )}
);
}
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
index 837e2f2..6d8ae1d 100644
--- a/src/app/(app)/dashboard/page.tsx
+++ b/src/app/(app)/dashboard/page.tsx
@@ -216,7 +216,7 @@ export default async function DashboardPage() {
) : (
-
+
)}
@@ -327,23 +327,18 @@ function ReadinessChecklist({
);
}
-function FirstSteps({ hasProvider }: { hasProvider: boolean }) {
+/**
+ * Premiers pas côté contenu (documents, projets, conversation). La connexion
+ * du provider n'apparaît PAS ici : elle est déjà portée par le bandeau
+ * « Mise en route » et la carte « Prise en main » de la sidebar — trois
+ * rappels identiques sur un même écran seraient du bruit.
+ */
+function FirstSteps() {
return (
- {!hasProvider && (
-
-
- 01
-
-
- Configurer un provider IA
- → /providers
-
-
+
+ );
+}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 9908c55..ab7d8e0 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -1,11 +1,13 @@
import { redirect } from "next/navigation";
-import { asc, desc, eq, sql } from "drizzle-orm";
+import { and, asc, desc, eq, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import {
conversations,
documents,
+ modelSettings,
projects,
+ providerKeys,
workflows,
} from "@/db/schema";
import { SidebarContent } from "./sidebar-content";
@@ -26,7 +28,7 @@ export default async function AppLayout({
role: session.user.role,
};
- const [convList, projectList, docList, workflowList] = await Promise.all([
+ const [convList, projectList, docList, workflowList, providerCount, modelCount] = await Promise.all([
db
.select({
id: conversations.id,
@@ -57,8 +59,37 @@ export default async function AppLayout({
.from(workflows)
.where(eq(workflows.userId, session.user.id))
.orderBy(asc(workflows.name)),
+ db
+ .select({ n: sql`count(*)::int` })
+ .from(providerKeys)
+ .where(
+ and(
+ eq(providerKeys.userId, session.user.id),
+ eq(providerKeys.isActive, true)
+ )
+ )
+ .then((r) => r[0]?.n ?? 0),
+ db
+ .select({ n: sql`count(*)::int` })
+ .from(modelSettings)
+ .where(
+ and(
+ eq(modelSettings.userId, session.user.id),
+ eq(modelSettings.enabled, true)
+ )
+ )
+ .then((r) => r[0]?.n ?? 0),
]);
+ // Parcours de prise en main affiché dans la sidebar tant qu'il n'est pas
+ // complété. Document et conversation se déduisent des listes déjà chargées.
+ const onboarding = {
+ provider: providerCount > 0,
+ model: modelCount > 0,
+ document: docList.length > 0,
+ conversation: convList.length > 0,
+ };
+
return (
setOpen(false)}
forceOpen
/>
diff --git a/src/app/(app)/settings/connectors/page.tsx b/src/app/(app)/settings/connectors/page.tsx
index 1536d19..8db9bc0 100644
--- a/src/app/(app)/settings/connectors/page.tsx
+++ b/src/app/(app)/settings/connectors/page.tsx
@@ -1,6 +1,11 @@
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
-import { IconShieldLock, IconClock } from "@tabler/icons-react";
+import {
+ IconShieldLock,
+ IconClock,
+ IconCheck,
+ IconReceipt,
+} from "@tabler/icons-react";
import { auth } from "@/auth";
import { db } from "@/db";
import { connectorKeys, type ConnectorKey } from "@/db/schema";
@@ -18,11 +23,6 @@ const COMING_SOON: Array<{
description: string;
category: "official" | "commercial";
}> = [
- {
- label: "Judilibre",
- category: "official",
- description: "Cour de cassation — accès direct sans passer par PISTE.",
- },
{
label: "Doctrine",
category: "commercial",
@@ -106,6 +106,20 @@ export default async function ConnectorsPage() {
+
+
+ Open data — toujours actif
+
+
+
+
+
+
Bientôt
@@ -147,3 +161,53 @@ function ComingSoonCard({
);
}
+
+// Sources en données ouvertes : aucune authentification, donc actives en
+// permanence et sans carte de configuration. Affichées pour que l'utilisateur
+// sache qu'elles existent et que Louis peut les interroger directement.
+function OpenDataCard({
+ label,
+ description,
+ category,
+ href,
+}: {
+ label: string;
+ description: string;
+ category: "official" | "commercial";
+ href?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/app/(app)/settings/models/library/library-browser.tsx b/src/app/(app)/settings/models/library/library-browser.tsx
index b22f73a..4d7ef97 100644
--- a/src/app/(app)/settings/models/library/library-browser.tsx
+++ b/src/app/(app)/settings/models/library/library-browser.tsx
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { PROVIDER_CATALOG, type ProviderType } from "@/lib/providers/catalog";
+import { ProviderQuickAdd } from "@/components/provider-quick-add";
import { MODEL_PRICING } from "@/lib/providers/pricing";
/** H23 : prix par M de tokens (entrée/sortie) pour signaler le coût AVANT
@@ -464,9 +465,9 @@ function NoProviderState() {
La bibliothèque interroge l'API de vos providers pour vous
montrer leurs modèles disponibles.
-
+
+
+
);
}
diff --git a/src/app/(app)/settings/providers/actions.ts b/src/app/(app)/settings/providers/actions.ts
index eb16314..5b3a419 100644
--- a/src/app/(app)/settings/providers/actions.ts
+++ b/src/app/(app)/settings/providers/actions.ts
@@ -81,6 +81,113 @@ export async function createProviderKey(
return { ok: true };
}
+const testedCreateSchema = createSchema.extend({
+ force: z.literal("true").optional().or(z.literal("")),
+});
+
+export type TestedCreateResult =
+ | { ok: true; testStatus: "ok" | "skipped" }
+ | { ok: false; error: string; canForce?: boolean };
+
+/**
+ * Variante « tester avant d'enregistrer » de createProviderKey — utilisée par
+ * l'assistant /setup et le quick-add du chat. La clé n'est stockée que si le
+ * provider la reconnaît (ou si le test est impossible / forcé). Une clé
+ * refusée (401/403) n'est jamais enregistrée : c'est l'erreur la plus
+ * fréquente du premier lancement, autant la attraper au moment du collage.
+ */
+export async function createProviderKeyTested(
+ _prev: TestedCreateResult | null,
+ formData: FormData
+): Promise {
+ const userId = await requireUserId();
+
+ const parsed = testedCreateSchema.safeParse({
+ type: formData.get("type"),
+ label: formData.get("label"),
+ apiKey: formData.get("apiKey"),
+ baseUrl: formData.get("baseUrl") ?? "",
+ force: formData.get("force") ?? "",
+ });
+ if (!parsed.success) return { ok: false, error: "Champs invalides." };
+
+ const { type, label, apiKey, baseUrl } = parsed.data;
+ const force = parsed.data.force === "true";
+
+ if (baseUrl) {
+ try {
+ assertSafeUrl(baseUrl);
+ } catch (err) {
+ if (err instanceof SsrfError) return { ok: false, error: err.message };
+ throw err;
+ }
+ }
+
+ const status = await testProvider(
+ type as (typeof PROVIDER_TYPES)[number],
+ apiKey,
+ baseUrl || null
+ );
+
+ if (status === "auth_error") {
+ return {
+ ok: false,
+ error:
+ "Le provider a refusé cette clé. Vérifiez qu'elle est complète et toujours valide sur la console du provider.",
+ };
+ }
+ if (status === "network_error" && !force) {
+ return {
+ ok: false,
+ error:
+ "Impossible de joindre le provider pour vérifier la clé. Réessayez, ou enregistrez sans test.",
+ canForce: true,
+ };
+ }
+
+ const blob = encrypt(apiKey);
+
+ // Première clé de l'utilisateur → défaut d'office, pour que le chat soit
+ // utilisable immédiatement sans passage par les réglages.
+ const [existing] = await db
+ .select({ id: providerKeys.id })
+ .from(providerKeys)
+ .where(eq(providerKeys.userId, userId))
+ .limit(1);
+
+ try {
+ await db.insert(providerKeys).values({
+ userId,
+ type: type as (typeof PROVIDER_TYPES)[number],
+ label,
+ apiKeyCiphertext: blob.ciphertext,
+ apiKeyIv: blob.iv,
+ apiKeyTag: blob.tag,
+ baseUrl: baseUrl || null,
+ isDefault: !existing,
+ lastTestedAt: new Date(),
+ lastTestStatus: status,
+ });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Erreur inconnue";
+ if (msg.includes("provider_keys_user_label_idx")) {
+ return { ok: false, error: "Ce libellé est déjà utilisé." };
+ }
+ return { ok: false, error: "Impossible de créer la clé." };
+ }
+
+ await recordAudit({
+ userId,
+ action: "provider.add",
+ target: `${type}:${label}`,
+ });
+
+ revalidatePath("/settings/providers");
+ revalidatePath("/chat");
+ revalidatePath("/dashboard");
+ return { ok: true, testStatus: status === "ok" ? "ok" : "skipped" };
+}
+
export async function deleteProviderKey(id: string): Promise {
const userId = await requireUserId();
const [target] = await db
diff --git a/src/app/(app)/settings/usage/page.tsx b/src/app/(app)/settings/usage/page.tsx
index 22eaf87..bcaac39 100644
--- a/src/app/(app)/settings/usage/page.tsx
+++ b/src/app/(app)/settings/usage/page.tsx
@@ -10,6 +10,7 @@ import {
formatTotals,
} from "@/lib/providers/pricing";
import { getUserMonthlyQuotaCents } from "@/lib/usage/quota";
+import { aggregateToolStats } from "@/lib/observability/query";
export default async function UsagePage() {
const session = await auth();
@@ -100,6 +101,11 @@ export default async function UsagePage() {
.where(and(eq(conversations.userId, userId)))
.then((r) => r[0].n);
+ // Fiabilité des outils ce mois-ci (Légifrance, Pappers, RAG, génération de
+ // documents, MCP) — latence + taux de succès. Répond à « quel outil rame ou
+ // échoue », distinct du coût IA.
+ const toolStats = await aggregateToolStats({ since: monthStart, userId });
+
return (
@@ -257,6 +263,68 @@ export default async function UsagePage() {
+
+
+
+ Fiabilité des outils.
+
+ {toolStats.totalCalls > 0 && (
+
+ {toolStats.totalCalls} appel
+ {toolStats.totalCalls > 1 ? "s" : ""} ce mois ·{" "}
+ {Math.round(toolStats.successRate)} % de succès
+
+ )}
+
+
+ {toolStats.byTool.length === 0 ? (
+
+ Aucun appel d'outil ce mois-ci (Légifrance, Pappers,
+ recherche documentaire, génération de documents, MCP).
+