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) })}
- {mode === "council" && ( -

- {(() => { - const debaters = Math.max(0, agentCount - 1); - const calls = estimateCalls({ mode: "council", agents: agentCount, rounds }); - return ( - <> - - - Coût estimé : {calls} appel{calls > 1 ? "s" : ""} LLM par - question — {debaters} débatteur{debaters > 1 ? "s" : ""} sur{" "} - {rounds} tour{rounds > 1 ? "s" : ""}, plus 1 synthèse finale. - - - ); - })()} -

- )} + {(() => { + const calls = estimateCalls({ mode, agents: agentCount, rounds }); + // On n'affiche l'avertissement de coût que pour les runs multi-appels + // (séquentiel mono-agent = 1 appel, rien à signaler). + if (calls <= 1) return null; + const debaters = Math.max(0, agentCount - 1); + const breakdown: string = + mode === "council" + ? `${debaters} débatteur${debaters > 1 ? "s" : ""} sur ${rounds} tour${rounds > 1 ? "s" : ""}, plus 1 synthèse finale.` + : mode === "iterative" + ? `${rounds} tour${rounds > 1 ? "s" : ""} d'approfondissement${agentCount > 1 ? ", plus 1 synthèse finale." : "."}` + : mode === "parallel" + ? `${debaters} agent${debaters > 1 ? "s" : ""} en parallèle, plus 1 synthèse finale.` + : mode === "maestro" + ? `estimation — le Maestro décide en direct qui consulter (${debaters} agent${debaters > 1 ? "s" : ""} disponibles, rappels possibles).` + : `1 appel par agent (${agentCount} en chaîne).`; + return ( +

+ + + Coût estimé : {calls} appel{calls > 1 ? "s" : ""} LLM par + question — {breakdown} + +

+ ); + })()} {isPreset && (

diff --git a/src/app/(app)/board/[id]/pipeline-workflow.tsx b/src/app/(app)/board/[id]/pipeline-workflow.tsx index 83a61ce..b64576d 100644 --- a/src/app/(app)/board/[id]/pipeline-workflow.tsx +++ b/src/app/(app)/board/[id]/pipeline-workflow.tsx @@ -16,6 +16,7 @@ import { type NodeTypes, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; +import { IconLoader2 } from "@tabler/icons-react"; import type { Pipeline, PipelineAgent, ProviderKey } from "@/db/schema"; import type { AgentSourceFolder, @@ -76,19 +77,25 @@ const NODE_GAP_X = 80; const NODE_HEIGHT = 200; const NODE_GAP_Y = 80; +type CanvasMode = "sequential" | "council" | "parallel" | "iterative" | "maestro"; + /** * Calcule les positions de chaque node selon le mode : * - sequential : grille horizontale (gauche → droite) + * - iterative : même chaîne horizontale (le chercheur boucle, puis synthèse) * - council : synthétiseur centré en bas, débatteurs en arc au-dessus * - parallel : synthétiseur en bas, workers étalés au-dessus + * - maestro : même disposition que parallel — le Maestro centré en bas, + * l'équipe au-dessus ; seules les flèches s'inversent (il + * délègue vers les membres au lieu de recevoir leurs sorties). */ function layoutNodes( agents: PipelineAgent[], - mode: "sequential" | "council" | "parallel" + mode: CanvasMode ): Array<{ x: number; y: number }> { if (agents.length === 0) return []; - if (mode === "sequential") { + if (mode === "sequential" || mode === "iterative") { return agents.map((_, i) => ({ x: i * (NODE_WIDTH + NODE_GAP_X), y: 0, @@ -125,15 +132,16 @@ function layoutNodes( * - sequential : chaîne A → B → C * - council & parallel : chaque worker pointe vers le synthétiseur ; en * council on ajoute des edges de débat (workers ↔ workers) en pointillés + * - maestro : flèches inversées (Maestro → membres) — c'est lui qui délègue */ function buildEdges( agents: PipelineAgent[], - mode: "sequential" | "council" | "parallel", + mode: CanvasMode, liveStates: Record | undefined ): Edge[] { if (agents.length < 2) return []; - if (mode === "sequential") { + if (mode === "sequential" || mode === "iterative") { return agents.slice(0, -1).map((a, i) => { const next = agents[i + 1]; const active = @@ -148,18 +156,30 @@ function buildEdges( }); } - // council & parallel : workers → synthétiseur + // council & parallel : workers → synthétiseur. Maestro : sens inverse, + // le terminal délègue vers chaque membre de l'équipe. const synth = agents[agents.length - 1]; const workers = agents.slice(0, -1); const edges: Edge[] = workers.map((w) => { - const active = liveStates?.[w.id] === "done"; - return { - id: `${w.id}->${synth.id}`, - source: w.id, - target: synth.id, - type: "animated", - data: { active }, - }; + const active = + mode === "maestro" + ? liveStates?.[w.id] === "active" + : liveStates?.[w.id] === "done"; + return mode === "maestro" + ? { + id: `${synth.id}->${w.id}`, + source: synth.id, + target: w.id, + type: "animated", + data: { active }, + } + : { + id: `${w.id}->${synth.id}`, + source: w.id, + target: synth.id, + type: "animated", + data: { active }, + }; }); // En council, on rajoute des edges de débat entre workers (en pointillés @@ -198,9 +218,10 @@ function PipelineWorkflowInner({ const [pendingDelete, setPendingDelete] = useState(null); const [pending, startTransition] = useTransition(); const editable = !pipeline.isPreset && !pending; - const mode = (pipeline.mode as "sequential" | "council" | "parallel") ?? "sequential"; + const mode = (pipeline.mode as CanvasMode) ?? "sequential"; // H7 : le drag ne ré-ordonne l'exécution QU'en mode séquentiel (en - // council/parallel, la position n'a aucun effet sur l'ordre → drag trompeur). + // council/parallel/itératif, la position n'a aucun effet sur l'ordre → drag + // trompeur). En itératif, l'ordre est figé (chercheur → … → synthèse). const dragEnabled = editable && mode === "sequential" && agents.length > 1; const handleDelete = useCallback((agent: PipelineAgent) => { @@ -347,10 +368,10 @@ function PipelineWorkflowInner({ // Hauteur du canvas en viewport units pour que les nodes (280×200) // gardent une taille lisible même avec 5+ agents en ligne. - // - sequential : 60vh (min 480px) - // - council/parallel : 70vh (min 580px) + // - sequential/itératif (chaîne horizontale) : 60vh (min 480px) + // - council/parallel (arc) : 70vh (min 580px) const canvasStyle: React.CSSProperties = - mode === "sequential" + mode === "sequential" || mode === "iterative" ? { height: "60vh", minHeight: 480 } : { height: "70vh", minHeight: 580 }; @@ -366,6 +387,17 @@ function PipelineWorkflowInner({ className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,transparent,color-mix(in_oklch,var(--color-foreground)_2%,transparent)_70%,color-mix(in_oklch,var(--color-foreground)_4%,transparent))]" /> + {/* Overlay pendant une mutation (reorder/suppression/reset/layout) : + bloque les interactions et signale le travail en cours. */} + {pending && ( +

+ + + Mise à jour… + +
+ )} + {/* Bouton reset : visible dès qu'au moins un agent a des coordonnées custom (canvasX/Y non null). */} {editable && hasCustomLayout && ( diff --git a/src/app/(app)/board/actions.ts b/src/app/(app)/board/actions.ts index f20b3a2..4dd6333 100644 --- a/src/app/(app)/board/actions.ts +++ b/src/app/(app)/board/actions.ts @@ -21,7 +21,7 @@ export type ActionResultWith = BaseActionResult; const pipelineMetaSchema = z.object({ name: z.string().trim().min(1).max(120), description: z.string().trim().max(500).nullable().optional(), - mode: z.enum(["sequential", "council", "parallel", "iterative"]).optional(), + mode: z.enum(["sequential", "council", "parallel", "iterative", "maestro"]).optional(), rounds: z.number().int().min(1).max(6).optional(), }); @@ -216,7 +216,7 @@ export async function updatePipelineMeta( data: { name: string; description?: string | null; - mode?: "sequential" | "council" | "parallel" | "iterative"; + mode?: "sequential" | "council" | "parallel" | "iterative" | "maestro"; rounds?: number; } ): Promise { diff --git a/src/app/(app)/board/mode-meta.ts b/src/app/(app)/board/mode-meta.ts index 90a1f49..4e70d00 100644 --- a/src/app/(app)/board/mode-meta.ts +++ b/src/app/(app)/board/mode-meta.ts @@ -3,6 +3,7 @@ import { IconUsersGroup, IconLayoutGrid, IconRefresh, + IconWand, type Icon, } from "@tabler/icons-react"; @@ -10,7 +11,8 @@ export type PipelineModeKey = | "sequential" | "council" | "parallel" - | "iterative"; + | "iterative" + | "maestro"; /** * Métadonnées d'affichage centralisées pour les 3 modes d'orchestration. @@ -59,6 +61,14 @@ export const MODE_META: Record< "Le chercheur reprend ses notes à chaque tour pour creuser les lacunes, puis le dernier synthétise.", accent: "text-foreground/70 border-border", }, + maestro: { + icon: IconWand, + label: "Maestro", + short: "Routeur", + pitch: + "Le dernier agent dirige : il choisit qui consulter, avec quelle consigne, et peut re-déléguer avant de répondre.", + accent: "text-foreground/70 border-primary/40", + }, }; export function modeMeta(mode: string | null | undefined) { diff --git a/src/app/(app)/board/page.tsx b/src/app/(app)/board/page.tsx index 3819402..d24d83f 100644 --- a/src/app/(app)/board/page.tsx +++ b/src/app/(app)/board/page.tsx @@ -10,6 +10,7 @@ import { roleMeta } from "./agent-role-meta"; import { modeMeta } from "./mode-meta"; import { PipelineActionsMenu } from "./pipeline-actions-menu"; import { TryPipelineButton } from "./try-pipeline-button"; +import { ReloadButton } from "./reload-button"; export default async function BureauPage() { const session = await auth(); @@ -53,12 +54,22 @@ export default async function BureauPage() { const mMeta = modeMeta(pipeline.mode); const ModeIcon = mMeta.icon; return ( - - -
+
{agents.length} agent{agents.length > 1 ? "s" : ""}
- + + +
- +
); })}
@@ -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 ( +
+
+ +
+

+ {state === "approved" + ? "Action approuvée" + : state === "denied" + ? "Action refusée" + : "Louis demande votre accord"} +

+

+ Outil sensible :{" "} + {meta.chip}{" "} + + {data.toolName} + + {state === "pending" && !isLive && " — demande expirée ou résolue."} +

+ {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 && ( -
  1. - - 01 - - - Configurer un provider IA - → /providers - -
  2. - )}
  3. - {hasProvider ? "01" : "02"} + 01 @@ -352,7 +347,7 @@ function FirstSteps({ hasProvider }: { hasProvider: boolean }) {
  4. - {hasProvider ? "02" : "03"} + 02 @@ -361,7 +356,7 @@ function FirstSteps({ hasProvider }: { hasProvider: boolean }) {
  5. - {hasProvider ? "03" : "04"} + 03 diff --git a/src/app/(app)/getting-started.tsx b/src/app/(app)/getting-started.tsx new file mode 100644 index 0000000..fd67f3d --- /dev/null +++ b/src/app/(app)/getting-started.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import Link from "next/link"; +import { IconCheck, IconX } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +export type OnboardingState = { + provider: boolean; + model: boolean; + document: boolean; + conversation: boolean; +}; + +const DISMISS_KEY = "louis:gettingStartedDismissed"; +const DISMISS_EVENT = "louis:gettingStartedDismissed-change"; + +function subscribe(cb: () => void) { + window.addEventListener(DISMISS_EVENT, cb); + window.addEventListener("storage", cb); + return () => { + window.removeEventListener(DISMISS_EVENT, cb); + window.removeEventListener("storage", cb); + }; +} + +function getSnapshot(): string { + return window.localStorage.getItem(DISMISS_KEY) ?? "false"; +} + +// Côté serveur on considère la carte masquée : elle apparaît après +// hydratation seulement si l'utilisateur ne l'a pas écartée — évite le +// flash « carte affichée puis retirée » au chargement. +function getServerSnapshot(): string { + return "true"; +} + +const STEPS: { + key: keyof OnboardingState; + label: string; + href: string; +}[] = [ + { key: "provider", label: "Connecter une clé IA", href: "/settings/providers" }, + { key: "model", label: "Activer un modèle", href: "/settings/models/library" }, + { key: "document", label: "Importer un document", href: "/documents" }, + { key: "conversation", label: "Première conversation", href: "/chat" }, +]; + +/** + * Carte « Prise en main » persistante de la sidebar. Contrairement à la + * checklist du dashboard (qui disparaît dès provider+modèle configurés), + * celle-ci accompagne l'utilisateur jusqu'à sa première conversation, survit + * aux sessions, et reste écartable d'un clic (localStorage). + */ +export function GettingStarted({ + state, + onNavigate, +}: { + state: OnboardingState; + onNavigate?: () => void; +}) { + const dismissed = + useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) === "true"; + + const doneCount = STEPS.filter((s) => state[s.key]).length; + const allDone = doneCount === STEPS.length; + if (dismissed || allDone) return null; + + function dismiss() { + window.localStorage.setItem(DISMISS_KEY, "true"); + window.dispatchEvent(new Event(DISMISS_EVENT)); + } + + return ( +
    +
    +

    + Prise en main +

    +
    + + {doneCount}/{STEPS.length} + + +
    +
    + +
    +
    +
    + +
      + {STEPS.map((s) => { + const done = state[s.key]; + return ( +
    • + + + {done && } + + {s.label} + +
    • + ); + })} +
    +
    + ); +} 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). +

    + ) : ( +
      + {toolStats.byTool.map((t) => { + const rate = Math.round(t.successRate); + const healthy = rate >= 95; + const degraded = rate >= 80 && rate < 95; + return ( +
    • + + {t.toolName} + + + {t.calls} appel{t.calls > 1 ? "s" : ""} + + + {formatMs(t.avgMs)} + + {" "} + / {formatMs(t.maxMs)} + + + + {rate} % + +
    • + ); + })} +
    + )} +
    +
    +