Compare commits
10 Commits
aee86b811e
...
4e23828dae
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e23828dae | |||
| d824d16eed | |||
| faa1e82301 | |||
| aa108e847b | |||
| 476c89ce3d | |||
| 76ad3b62fd | |||
| 2bfa58f440 | |||
| bc61434f7c | |||
| d1255b926b | |||
| e6edf1a8bc |
+13
-2
@@ -27,8 +27,10 @@ PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RES
|
||||
PORTAINER_READ_ONLY=true
|
||||
MCP_PORTAINER_URL=http://mcp-portainer:3000
|
||||
|
||||
# ── Surface Telegram (Phase 1) ─────────────────────────────────────────
|
||||
TELEGRAM_BOT_TOKEN= # SECRET — token du bot
|
||||
# ── Surface Telegram (OPTIONNELLE) ─────────────────────────────────────
|
||||
# Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une
|
||||
# AUTRE surface (API/UI ci-dessous), sinon il refuse de démarrer (fail-closed).
|
||||
TELEGRAM_BOT_TOKEN= # SECRET — token du bot (vide = pas de Telegram)
|
||||
TELEGRAM_ALLOWED_USER_IDS= # liste d'IDs autorisés, séparés par virgule
|
||||
|
||||
# ── Backend CHLOVA ─────────────────────────────────────────────────────
|
||||
@@ -52,6 +54,15 @@ CHLOVA_TOTP_SECRET= # SECRET — secret TOTP (2FA)
|
||||
CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT
|
||||
# Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin.
|
||||
CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement)
|
||||
# Racine du SPA buildé servi same-origin. Défaut image = /app/web. Vide = pas de SPA.
|
||||
CHLOVA_WEB_ROOT= # laisser vide en conteneur (défaut /app/web)
|
||||
|
||||
# ── Auto-extension (Phase 5) ───────────────────────────────────────────
|
||||
# Si true, l'agent peut créer des assets en need-review (écrit + commit +
|
||||
# versionne + documente). Désactivé par défaut (fail-safe). Requiert un dépôt
|
||||
# git monté dans le conteneur à CHLOVA_REPO_ROOT.
|
||||
CHLOVA_AUTOEXT_ENABLED=false
|
||||
CHLOVA_REPO_ROOT=. # chemin du dépôt (working copy GitOps)
|
||||
# Domaine public derrière Traefik (label compose).
|
||||
CHLOVA_DOMAIN=chlova.example.com
|
||||
# Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI.
|
||||
|
||||
+104
@@ -6,6 +6,110 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.33.0] — 2026-06-23 — infra locale déployée (gitea + n8n-chlova)
|
||||
### Added
|
||||
- **`infra/gitea/docker-compose.yml`** : serveur git Gitea 1.26.4 sur l'hôte
|
||||
`local` (réseau `proxy`, `git.pogoo.app`), source du GitOps de CHLOVA.
|
||||
**Déployé** via Portainer (stack id 6, running).
|
||||
- **`infra/n8n-chlova/docker-compose.yml`** : n8n dédié à CHLOVA (2.20.8) sur
|
||||
`local` (`n8n-chlova.pogoo.app`), MCP natif interne `http://n8n-chlova:5678`.
|
||||
**Déployé** via Portainer (stack id 7, running healthy).
|
||||
### Changed
|
||||
- Topologie cible repliée sur **un seul hôte (`local`)** : tout sur le réseau
|
||||
`proxy`, joignable en interne par nom de conteneur.
|
||||
- `docker-compose.prod.yml` : `MCP_N8N_URL` → `n8n-chlova`, `PORTAINER_URL` →
|
||||
interne `http://portainer:9000` (même hôte), sidecar `mcp-portainer` ajouté au
|
||||
réseau `proxy` (pour joindre le serveur Portainer).
|
||||
- `docs/deploy.md` réécrit pour la topologie locale (DNS, gitea, push, users
|
||||
restreints, déploiement GitOps, vérif).
|
||||
|
||||
## [0.32.0] — 2026-06-23 — prêt au déploiement (GitOps Portainer, Phase 1)
|
||||
### Added
|
||||
- **`infra/docker-compose.prod.yml`** : compose de PRODUCTION pour GitOps
|
||||
Portainer. Sans `env_file` (secrets via variables de stack), réseau Traefik
|
||||
réel `proxy` (external), certresolver `letsencrypt`, backend joint `proxy`
|
||||
pour atteindre n8n en interne. Cible : env `vps-pogoo-002`.
|
||||
- **`docs/deploy.md`** : runbook Phase 1 — dépôt git, génération secrets,
|
||||
users restreints `chlova` (Portainer + n8n, étapes UI), variables de stack,
|
||||
déploiement, vérification, rollback, bascule Phase 2.
|
||||
- `config.assertHasSurface()` : refuse de démarrer si aucune surface (Telegram
|
||||
OU API/UI) n'est configurée. 3 tests (11 au total dans config).
|
||||
### Changed
|
||||
- **Surface Telegram rendue optionnelle** (`telegramBotToken` optionnel ;
|
||||
`index.ts` ne démarre la surface que si le token est présent). Permet le
|
||||
déploiement **UI-only** voulu. `.env.example` documenté en conséquence.
|
||||
- Typecheck vert, 78 tests verts.
|
||||
|
||||
## [0.31.0] — 2026-06-23 — app mobile (React Native / Expo)
|
||||
### Added
|
||||
- Package **`mobile/`** : app Expo SDK 56 (expo-router) réutilisant l'API backend.
|
||||
Écrans Login (mdp + TOTP), Chat (+ TTS expo-speech), Review (approuver/refuser).
|
||||
JWT en `expo-secure-store`, thème dark HUD, `EXPO_PUBLIC_API_BASE` configurable.
|
||||
`tsc --noEmit` vert. STT mobile reporté (lib native) ; TTS OK.
|
||||
- README racine : entrées `web/` et `mobile/`.
|
||||
|
||||
## [0.30.0] — 2026-06-23 — polish UI
|
||||
### Added
|
||||
- Icônes **Lucide** (SVG) partout (chat, voix, review, nav) — fini le texte/emoji.
|
||||
- Contexte `appdata` : état backend (phase/outils) + assets en attente partagés ;
|
||||
header affiche phase·outils, **badge de review** dans la nav, refresh après décision.
|
||||
### Changed
|
||||
- `Review` consomme `appdata` (plus de fetch local). Build OK, 0 vuln.
|
||||
|
||||
## [0.29.0] — 2026-06-23 — fin Phase 6 (voix v1)
|
||||
### Added
|
||||
- `useSpeech` : mode **mains-libres** + wake-word « CHLOVA » (`extractCommand`),
|
||||
écoute en boucle, micro en pause pendant le TTS (anti auto-écoute).
|
||||
- Chat : bouton "Libre" (mains-libres) ; en mains-libres les réponses sont lues
|
||||
d'office. Indicateur d'écoute. Build OK. (README web : section voix.)
|
||||
|
||||
## [0.28.0] — 2026-06-23 — début Phase 6 (voix)
|
||||
### Added
|
||||
- `web/src/useSpeech.ts` : hook voix 100 % navigateur (Web Speech API), STT
|
||||
(push-to-talk, fr-FR) + TTS, dégrade proprement si non supporté. Zéro backend/GPU.
|
||||
- Chat : bouton micro (dicter → envoyer), bascule "Voix ON/OFF" (persistée) qui lit
|
||||
les réponses à voix haute. Pas d'emoji comme icône (texte). Build OK.
|
||||
|
||||
## [0.27.0] — 2026-06-23 — fin Phase 5 (auto-extension v1)
|
||||
### Added
|
||||
- Outil local **`chlova.propose_asset`** (`src/autoext/tool.ts`) exposé à l'agent :
|
||||
propose un asset → write+commit+doc → need-review. Notion d'outil **sanctionné**
|
||||
(`ToolSpec.sanctioned`) autorisé par le gatekeeper mais audité.
|
||||
- Config `CHLOVA_AUTOEXT_ENABLED` (défaut false) + `CHLOVA_REPO_ROOT`. Câblage
|
||||
Phase 2 + flag. Prompt système Phase 2 mis à jour (palier honnête imposé).
|
||||
- Tests (2) : gatekeeper autorise un outil sanctionné ; tool propose. 75 tests.
|
||||
### Changed
|
||||
- `.env.example` + compose : flag autoext + montage dépôt (commenté).
|
||||
`docs/need-review.md` : section auto-extension. Compose revalidé, 0 vuln.
|
||||
|
||||
## [0.26.0] — 2026-06-23
|
||||
### Added
|
||||
- `src/autoext/git-committer.ts` : `GitCommitter` — commits ciblés (jamais
|
||||
`git add -A`), auteur CHLOVA, renvoie le SHA.
|
||||
- `src/autoext/auto-extension.ts` : `AutoExtensionService.propose` — write+doc →
|
||||
commit+version → enregistre l'asset en need-review (privilégié = BLOQUÉ, aucun
|
||||
sursis) → alerte `asset_created` (version + commit + doc). N'exécute jamais l'asset.
|
||||
- Alerte `asset_created`. 3 tests (dépôt git temp). 73 tests, 0 vuln.
|
||||
|
||||
## [0.25.0] — 2026-06-23 — début Phase 5 (auto-extension)
|
||||
### Added
|
||||
- `src/autoext/artifact-writer.ts` : écriture d'un asset auto-créé (artefact +
|
||||
doc générée depuis le gabarit), chemins sanitizés (anti-traversée), version
|
||||
semver validée. `slugify`, `artifactRelPath`, `docRelPath`, `renderDoc`,
|
||||
`writeArtifact`. 5 tests (dépôt temp).
|
||||
|
||||
## [0.24.0] — 2026-06-23 — fin Phase 4 (UI v1 : chat + review)
|
||||
### Added
|
||||
- UI : vue **Review** (`web/src/pages/Review.tsx`) — liste des assets en attente,
|
||||
approuver / refuser (confirmation sur refus, palier coloré), refresh, 401→logout.
|
||||
- Backend sert le **SPA same-origin** (`@fastify/static` 9.1.3 + fallback routes
|
||||
client) si `CHLOVA_WEB_ROOT` présent ; config `webRoot`.
|
||||
### Changed
|
||||
- Dockerfile multi-stage : build **web + API** (contexte = racine dépôt), image
|
||||
embarque le SPA (`/app/web`, `CHLOVA_WEB_ROOT` par défaut). Compose : contexte
|
||||
`..`, `dockerfile: orchestrator/Dockerfile`, image `chlova/backend:0.2.0`.
|
||||
Compose revalidé. 65 tests, 0 vuln.
|
||||
|
||||
## [0.23.0] — 2026-06-23
|
||||
### Added
|
||||
- UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via
|
||||
|
||||
@@ -17,7 +17,9 @@ Ollama **cloud**.
|
||||
|---|---|
|
||||
| `docs/` | Architecture, sécurité, versioning, paliers de risque, gabarit d'asset |
|
||||
| `infra/` | docker-compose de la stack + socket-proxy + notes réseau |
|
||||
| `orchestrator/` | Le cerveau (TypeScript/Node, Fastify) |
|
||||
| `orchestrator/` | Le cerveau (TypeScript/Node, Fastify) + API |
|
||||
| `web/` | UI web/PWA (React/Vite/Tailwind) — chat, review, voix |
|
||||
| `mobile/` | App mobile (React Native / Expo) — chat, review |
|
||||
| `workflows-n8n/` | Exports JSON des workflows (le dépôt fait foi) |
|
||||
|
||||
## Démarrage (dev)
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Déploiement CHLOVA (Phase 1 — lecture seule, GitOps Portainer)
|
||||
|
||||
Mise en production réelle sur le homelab. **Tout sur l'hôte `local`**
|
||||
(environnement Portainer endpoint 3), aux côtés du serveur Portainer et de
|
||||
Traefik. Pile dédiée à CHLOVA :
|
||||
|
||||
| Stack | État | Rôle |
|
||||
|---|---|---|
|
||||
| `gitea` (endpoint 3) | **déployé** | dépôt git source du GitOps (`git.pogoo.app`) |
|
||||
| `n8n-chlova` (endpoint 3) | **déployé** | n8n dédié à CHLOVA (`n8n-chlova.pogoo.app`) |
|
||||
| `chlova` (endpoint 3) | à déployer | backend + ollama + sidecars (`chlova.pogoo.app`) |
|
||||
|
||||
> **Modèle de risque.** Phase 1 = lecture seule : `CHLOVA_PHASE=1`,
|
||||
> `PORTAINER_READ_ONLY=true`, MCP read-only filtré. Aucune écriture branchée.
|
||||
> Les secrets de CHLOVA ne transitent **jamais** par l'agent : ils sont saisis
|
||||
> par l'opérateur dans les variables de stack Portainer (UI) — voir §4.
|
||||
|
||||
Réseau commun : `proxy` (external) — Traefik, Portainer, gitea, n8n-chlova et le
|
||||
backend CHLOVA y sont tous attachés, donc joignables **en interne** par nom de
|
||||
conteneur. Resolver TLS : `letsencrypt`. Entrypoint : `websecure`.
|
||||
|
||||
Compose de prod : [`infra/docker-compose.prod.yml`](../infra/docker-compose.prod.yml).
|
||||
Build de l'image : **par Portainer sur `local`** (GitOps : clone du dépôt gitea
|
||||
+ `docker compose build`). Contexte de build = racine du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## 0. DNS (préalable)
|
||||
|
||||
Faire pointer vers l'IP de l'hôte `local` (la même que `traefik.pogoo.app`) :
|
||||
`git.pogoo.app`, `n8n-chlova.pogoo.app`, `chlova.pogoo.app`. Sans ça, Traefik ne
|
||||
peut pas émettre les certificats letsencrypt ni router.
|
||||
|
||||
## 1. Gitea — admin + dépôt (UI, une fois)
|
||||
|
||||
`gitea` est déployé, installeur **ouvert** (tu crées le 1er compte = admin).
|
||||
|
||||
1. Ouvrir `https://git.pogoo.app` → compléter l'install (DB = SQLite déjà
|
||||
réglée) → créer le compte admin.
|
||||
2. **New repository** : `chlova` (privé).
|
||||
3. **Settings → Applications → Generate token** (scope `write:repository`) :
|
||||
sert à pousser le code (§2) et au clone GitOps par Portainer (§4).
|
||||
|
||||
> L'enregistrement public est désactivé (`DISABLE_REGISTRATION=true`) : seul
|
||||
> l'admin existe.
|
||||
|
||||
## 2. Pousser le dépôt CHLOVA dans gitea
|
||||
|
||||
Le code est sur le poste de build. Ajouter le remote gitea et pousser `main` :
|
||||
|
||||
```bash
|
||||
git remote add gitea https://git.pogoo.app/<admin>/chlova.git
|
||||
git push gitea main # auth : <admin> + token de l'étape 1.3
|
||||
```
|
||||
|
||||
## 3. Users restreints `chlova` (UI)
|
||||
|
||||
> Le MCP Portainer n'expose pas la gestion users/tokens : étapes UI. Principe :
|
||||
> CHLOVA n'accède qu'à ses ressources, tokens à portée minimale.
|
||||
|
||||
### 3a. Portainer — user `chlova` + token
|
||||
1. **Users → Add user** : `chlova`, non-admin.
|
||||
2. **Environments → local → Access** : rôle le plus bas suffisant. *(CE = RBAC
|
||||
par environnement, pas par stack ; le vrai verrou Phase 1 reste
|
||||
`PORTAINER_READ_ONLY=true`.)*
|
||||
3. Connecté en `chlova` → **My account → Access tokens → Add token** →
|
||||
`PORTAINER_MCP_AUTH_TOKEN`.
|
||||
|
||||
### 3b. n8n-chlova — MCP token
|
||||
1. `https://n8n-chlova.pogoo.app` → créer le compte propriétaire.
|
||||
2. Activer le serveur **MCP natif**, générer le **MCP Access Token** →
|
||||
`MCP_N8N_AUTH_TOKEN`. Endpoint interne : `http://n8n-chlova:5678/mcp-server/http`.
|
||||
|
||||
## 4. Secrets login UI (générés, jamais commités)
|
||||
|
||||
```bash
|
||||
cd orchestrator
|
||||
npm run provision-auth -- <admin_user> '<mot_de_passe_fort>'
|
||||
```
|
||||
|
||||
→ `CHLOVA_ADMIN_PASSWORD_HASH`, `CHLOVA_TOTP_SECRET`, `CHLOVA_JWT_SECRET` +
|
||||
`otpauth://…` (scanner dans l'app TOTP). À saisir dans les variables de stack (§5).
|
||||
|
||||
## 5. Déploiement du stack `chlova`
|
||||
|
||||
`infra/docker-compose.prod.yml` n'a **aucun** `env_file` : toutes les variables
|
||||
sont des **variables de stack** Portainer (les secrets y sont saisis par
|
||||
l'opérateur, jamais par l'agent).
|
||||
|
||||
| Variable | Valeur | Secret ? |
|
||||
|---|---|---|
|
||||
| `CHLOVA_DOMAIN` | `chlova.pogoo.app` | non |
|
||||
| `CHLOVA_PHASE` | `1` | non |
|
||||
| `PORTAINER_READ_ONLY` | `true` | non |
|
||||
| `PORTAINER_URL` | `http://portainer:9000` (défaut, interne) | non |
|
||||
| `OLLAMA_API_KEY` | clé Ollama cloud | **oui** |
|
||||
| `OLLAMA_MODEL` | `qwen3:cloud` | non |
|
||||
| `MCP_N8N_AUTH_TOKEN` | token MCP n8n-chlova (§3b) | **oui** |
|
||||
| `PORTAINER_MCP_AUTH_TOKEN` | token user chlova (§3a) | **oui** |
|
||||
| `CHLOVA_ADMIN_USER` | ex. `kantin` | non |
|
||||
| `CHLOVA_ADMIN_PASSWORD_HASH` | (§4) | **oui** |
|
||||
| `CHLOVA_TOTP_SECRET` | (§4) | **oui** |
|
||||
| `CHLOVA_JWT_SECRET` | (§4) | **oui** |
|
||||
|
||||
**Stacks → Add stack → Git repository** :
|
||||
- Repository URL = `http://gitea:3000/<admin>/chlova.git` (clone interne) ou
|
||||
`https://git.pogoo.app/<admin>/chlova.git`.
|
||||
- Authentication = user gitea + token (§1.3).
|
||||
- Reference = `refs/heads/main` ; Compose path = `infra/docker-compose.prod.yml`.
|
||||
- Environment variables = tableau ci-dessus.
|
||||
- **Deploy**. Portainer clone, build l'image et lance le stack.
|
||||
|
||||
## 6. Vérification
|
||||
|
||||
1. Stack `chlova` *running* ; conteneurs `backend`, `ollama`, `mcp-portainer`,
|
||||
`socket-proxy` *up*.
|
||||
2. Logs `backend` : `API/UI activée (auth configurée)` + `healthcheck interne
|
||||
prêt`. Pas d'erreur fail-closed.
|
||||
3. `https://chlova.pogoo.app` → page login (mot de passe + TOTP).
|
||||
4. Connexion → Chat répond ; outils MCP read-only (n8n + Portainer) listés.
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
GitOps : revert du commit compose + redeploy, ou **Stacks → chlova →
|
||||
Stop/Remove**. Volume `chlova-data` (SQLite) persiste ; le supprimer pour
|
||||
repartir de zéro.
|
||||
|
||||
## 8. Passage Phase 2 (plus tard)
|
||||
|
||||
Écriture sous gatekeeper + need-review : `CHLOVA_PHASE=2` et
|
||||
`PORTAINER_READ_ONLY=false` (mutations alors filtrées par gatekeeper + paliers
|
||||
de risque). Uniquement après validation du cerveau en lecture seule. Voir
|
||||
[`docs/need-review.md`](./need-review.md).
|
||||
+16
-3
@@ -62,6 +62,19 @@ casse jamais l'agent. Le mail est envoyé par le workflow
|
||||
`workflows-n8n/chlova-alerts.v1.0.0.json` (doc : `docs/assets/workflow-chlova-alerts.md`).
|
||||
Le payload ne contient aucun secret.
|
||||
|
||||
## Reste à faire (Phase 4+)
|
||||
- Auto-extension : CHLOVA génère commit + version + doc d'un asset **avant**
|
||||
de le passer en need-review (Phase 4).
|
||||
## Auto-extension (Phase 5 — implémenté)
|
||||
Quand aucune capacité n'existe, l'agent appelle l'outil **sanctionné**
|
||||
`chlova.propose_asset` (`src/autoext/`) qui :
|
||||
1. écrit l'artefact (workflow/outil) + sa doc (gabarit) dans le dépôt ;
|
||||
2. **commit + versionne** (commit ciblé, jamais `git add -A`) ;
|
||||
3. enregistre l'asset en need-review (privilégié → **BLOQUÉ**, aucun sursis) ;
|
||||
4. émet l'alerte `asset_created` (version + commit + doc).
|
||||
|
||||
L'asset n'est **jamais exécuté** par ce canal : il attend la review (/approve).
|
||||
Un outil sanctionné est autorisé par le gatekeeper mais **audité** ; il ne touche
|
||||
pas l'infra. Désactivé par défaut (`CHLOVA_AUTOEXT_ENABLED=false`) ; requiert un
|
||||
dépôt git monté (`CHLOVA_REPO_ROOT`). Le LLM ne peut pas sous-classer un asset
|
||||
privilégié (palier honnête imposé par le prompt + non négociable côté review).
|
||||
|
||||
## Reste à faire (Phase 6+)
|
||||
- Voix : STT + wake-word + TTS dans l'UI (API déjà prête).
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# CHLOVA — compose de PRODUCTION (déploiement GitOps via Portainer).
|
||||
#
|
||||
# Différences avec ../infra/docker-compose.yml (dev local) :
|
||||
# • Aucun `env_file` : le clone git ne contient PAS de .env. TOUS les secrets
|
||||
# et réglages arrivent par les VARIABLES DE STACK Portainer (interpolation
|
||||
# ${VAR}). L'agent ne voit jamais de secret en clair (CLAUDE.md).
|
||||
# • Réseau Traefik réel du homelab = `proxy` (external), pas `traefik-public`.
|
||||
# • certresolver réel = `letsencrypt` (cf. stack proxy), pas `le`.
|
||||
# • Le backend rejoint `proxy` pour joindre n8n en interne (http://n8n:5678).
|
||||
#
|
||||
# Cible : environnement Portainer `vps-pogoo-002` (endpoint 11).
|
||||
# Build : Portainer clone ce dépôt et build l'image sur le VPS (GitOps).
|
||||
# → chemin compose = infra/docker-compose.prod.yml ; contexte build = racine.
|
||||
#
|
||||
# Phase 1 (lecture seule) : CHLOVA_PHASE=1, PORTAINER_READ_ONLY=true.
|
||||
# Voir docs/deploy.md pour la procédure complète + variables de stack à fournir.
|
||||
|
||||
name: chlova
|
||||
|
||||
services:
|
||||
# ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ──────
|
||||
ollama:
|
||||
image: ollama/ollama:0.6.8
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
OLLAMA_API_KEY: ${OLLAMA_API_KEY:?OLLAMA_API_KEY requis}
|
||||
OLLAMA_HOST: 0.0.0.0:11434
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
networks:
|
||||
- chlova-internal
|
||||
- chlova-egress
|
||||
# AUCUN port publié : Ollama n'a pas d'auth native, jamais exposé.
|
||||
|
||||
# ── socket-proxy : accès Docker filtré, LECTURE SEULE (Phase 1) ─────────
|
||||
socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:0.3.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
IMAGES: 1
|
||||
NETWORKS: 1
|
||||
VOLUMES: 1
|
||||
SERVICES: 1
|
||||
TASKS: 1
|
||||
NODES: 1
|
||||
INFO: 1
|
||||
VERSION: 1
|
||||
POST: 0
|
||||
EXEC: 0
|
||||
AUTH: 0
|
||||
SECRETS: 0
|
||||
CONFIGS: 0
|
||||
BUILD: 0
|
||||
COMMIT: 0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- chlova-internal
|
||||
# AUCUN port publié.
|
||||
|
||||
# ── MCP Portainer (portainer/portainer-mcp) — read-only en Phase 1 ──────
|
||||
mcp-portainer:
|
||||
image: portainer/portainer-mcp:0.6.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORTAINER_URL: ${PORTAINER_URL:-http://portainer:9000} # interne : Portainer sur le même hôte (local), réseau proxy
|
||||
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # token user chlova restreint
|
||||
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : NE PAS passer à false
|
||||
DOCKER_HOST: tcp://socket-proxy:2375
|
||||
depends_on:
|
||||
- socket-proxy
|
||||
networks:
|
||||
- chlova-internal # joignable par le backend
|
||||
- proxy # joint le serveur Portainer (http://portainer:9000)
|
||||
# AUCUN port publié.
|
||||
|
||||
# ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ──────────────
|
||||
backend:
|
||||
build:
|
||||
context: .. # racine du dépôt (image = API + SPA web)
|
||||
dockerfile: orchestrator/Dockerfile
|
||||
image: chlova/backend:0.2.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# — Runtime / phase —
|
||||
CHLOVA_ENV: ${CHLOVA_ENV:-production}
|
||||
CHLOVA_LOG_LEVEL: ${CHLOVA_LOG_LEVEL:-info}
|
||||
CHLOVA_PHASE: ${CHLOVA_PHASE:-1} # 1 = lecture seule
|
||||
CHLOVA_DB_PATH: ${CHLOVA_DB_PATH:-/app/data/chlova.db}
|
||||
# — Ollama (cloud proxy) —
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
OLLAMA_API_KEY: ${OLLAMA_API_KEY:?requis}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:cloud}
|
||||
# — MCP n8n (natif, interne via réseau proxy) —
|
||||
MCP_N8N_URL: ${MCP_N8N_URL:-http://n8n-chlova:5678/mcp-server/http}
|
||||
MCP_N8N_AUTH_TOKEN: ${MCP_N8N_AUTH_TOKEN:?requis}
|
||||
# — MCP Portainer (sidecar) —
|
||||
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000}
|
||||
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis}
|
||||
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true}
|
||||
# — Alertes (Phase 3) : vide = log-only —
|
||||
ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-}
|
||||
# — API/UI (surface exposée) : login fort —
|
||||
CHLOVA_ADMIN_USER: ${CHLOVA_ADMIN_USER:?requis}
|
||||
CHLOVA_ADMIN_PASSWORD_HASH: ${CHLOVA_ADMIN_PASSWORD_HASH:?requis}
|
||||
CHLOVA_TOTP_SECRET: ${CHLOVA_TOTP_SECRET:?requis}
|
||||
CHLOVA_JWT_SECRET: ${CHLOVA_JWT_SECRET:?requis}
|
||||
# — Auto-extension (Phase 5) : off par défaut —
|
||||
CHLOVA_AUTOEXT_ENABLED: ${CHLOVA_AUTOEXT_ENABLED:-false}
|
||||
CHLOVA_REPO_ROOT: ${CHLOVA_REPO_ROOT:-/app/repo}
|
||||
volumes:
|
||||
- chlova-data:/app/data # SQLite (table assets, P2+)
|
||||
depends_on:
|
||||
- ollama
|
||||
- mcp-portainer
|
||||
networks:
|
||||
- chlova-internal
|
||||
- proxy # joint Traefik + n8n (réseau homelab)
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: proxy
|
||||
traefik.http.routers.chlova.rule: Host(`${CHLOVA_DOMAIN:-chlova.pogoo.app}`)
|
||||
traefik.http.routers.chlova.entrypoints: websecure
|
||||
traefik.http.routers.chlova.tls: "true"
|
||||
traefik.http.routers.chlova.tls.certresolver: letsencrypt
|
||||
traefik.http.services.chlova.loadbalancer.server.port: "8080"
|
||||
|
||||
networks:
|
||||
chlova-internal:
|
||||
internal: true # aucune route vers l'extérieur
|
||||
chlova-egress:
|
||||
driver: bridge # sortie contrôlée (Ollama → ollama.com)
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true # réseau Traefik existant du homelab (cf. stacks proxy/n8n)
|
||||
|
||||
volumes:
|
||||
ollama-data:
|
||||
chlova-data:
|
||||
@@ -78,19 +78,25 @@ services:
|
||||
# ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ──────────────
|
||||
backend:
|
||||
build:
|
||||
context: ../orchestrator # Dockerfile ajouté en Phase 1
|
||||
image: chlova/backend:0.1.0 # tag versionné local
|
||||
context: .. # racine du dépôt (image = API + SPA web)
|
||||
dockerfile: orchestrator/Dockerfile
|
||||
image: chlova/backend:0.2.0 # tag versionné local (API+UI)
|
||||
restart: unless-stopped
|
||||
env_file: ../.env
|
||||
environment:
|
||||
CHLOVA_ENV: ${CHLOVA_ENV:-production}
|
||||
CHLOVA_PHASE: ${CHLOVA_PHASE:-1} # 1 = lecture seule (défaut) ; 2 = écriture sous review
|
||||
ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} # Phase 3 : vide = alertes log-only
|
||||
CHLOVA_AUTOEXT_ENABLED: ${CHLOVA_AUTOEXT_ENABLED:-false} # Phase 5 : off par défaut
|
||||
CHLOVA_REPO_ROOT: ${CHLOVA_REPO_ROOT:-/app/repo}
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
MCP_N8N_URL: ${MCP_N8N_URL} # endpoint MCP natif de n8n
|
||||
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000}
|
||||
volumes:
|
||||
- chlova-data:/app/data # SQLite (table assets, P2+)
|
||||
# Auto-extension (Phase 5, off par défaut) : monter le dépôt git ici pour
|
||||
# que CHLOVA puisse committer les assets créés.
|
||||
# - /srv/chlova-repo:/app/repo
|
||||
depends_on:
|
||||
- ollama
|
||||
- mcp-portainer
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Gitea — serveur git auto-hébergé du homelab, source du GitOps de CHLOVA.
|
||||
#
|
||||
# Déployé sur l'hôte `local` (Portainer endpoint 3), réseau `proxy`, exposé en
|
||||
# https://git.pogoo.app via Traefik (resolver letsencrypt). DB = SQLite (volume).
|
||||
# Installeur laissé ouvert au 1er run : l'opérateur crée le compte admin, puis
|
||||
# l'enregistrement public reste désactivé. Voir docs/deploy.md §1.
|
||||
#
|
||||
# Image épinglée (jamais :latest). Aucune donnée sensible ici (admin créé en UI).
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.26.4
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- TZ=Europe/Paris
|
||||
- GITEA__server__DOMAIN=git.pogoo.app
|
||||
- GITEA__server__ROOT_URL=https://git.pogoo.app/
|
||||
- GITEA__server__DISABLE_SSH=true # push via HTTPS uniquement
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__service__DISABLE_REGISTRATION=true
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.gitea.rule=Host(`git.pogoo.app`)"
|
||||
- "traefik.http.routers.gitea.entrypoints=websecure"
|
||||
- "traefik.http.routers.gitea.tls=true"
|
||||
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
@@ -0,0 +1,67 @@
|
||||
# n8n dédié à CHLOVA — instance séparée du n8n de prospection (qui vit sur le VPS).
|
||||
#
|
||||
# Déployé sur l'hôte `local` (Portainer endpoint 3), réseau `proxy`, exposé en
|
||||
# https://n8n-chlova.pogoo.app via Traefik (resolver letsencrypt). Joignable en
|
||||
# interne par le backend CHLOVA : http://n8n-chlova:5678 (MCP natif sur
|
||||
# /mcp-server/http). Voir docs/deploy.md §3b.
|
||||
#
|
||||
# Image épinglée (même tag que l'instance existante, connue bonne). La clé de
|
||||
# chiffrement N8N_ENCRYPTION_KEY n'est PAS fournie ici : n8n l'auto-génère et la
|
||||
# persiste dans le volume au 1er démarrage (instance mono-nœud).
|
||||
|
||||
services:
|
||||
n8n:
|
||||
image: docker.n8n.io/n8nio/n8n:2.20.8
|
||||
container_name: n8n-chlova
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- n8n_chlova_data:/home/node/.n8n
|
||||
environment:
|
||||
- N8N_HOST=n8n-chlova.pogoo.app
|
||||
- N8N_PORT=5678
|
||||
- N8N_PROTOCOL=https
|
||||
- WEBHOOK_URL=https://n8n-chlova.pogoo.app/
|
||||
- N8N_EDITOR_BASE_URL=https://n8n-chlova.pogoo.app/
|
||||
- N8N_SECURE_COOKIE=true
|
||||
- N8N_TRUST_PROXY=true
|
||||
- N8N_PROXY_HOPS=1
|
||||
- NODE_ENV=production
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_VERSION_NOTIFICATIONS_ENABLED=false
|
||||
- N8N_HIRING_BANNER_ENABLED=false
|
||||
- N8N_PERSONALIZATION_ENABLED=false
|
||||
- N8N_PUBLIC_API_DISABLED=true
|
||||
- N8N_PUBLIC_API_SWAGGER_UI_DISABLED=true
|
||||
- N8N_BLOCK_ENV_ACCESS_IN_NODE=true
|
||||
- GENERIC_TIMEZONE=Europe/Paris
|
||||
- TZ=Europe/Paris
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:5678/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.n8n-chlova.rule=Host(`n8n-chlova.pogoo.app`)"
|
||||
- "traefik.http.routers.n8n-chlova.entrypoints=websecure"
|
||||
- "traefik.http.routers.n8n-chlova.tls=true"
|
||||
- "traefik.http.routers.n8n-chlova.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.n8n-chlova.loadbalancer.server.port=5678"
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
n8n_chlova_data:
|
||||
driver: local
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
*.log
|
||||
# Builds natifs générés (prebuild)
|
||||
/ios
|
||||
/android
|
||||
.env*.local
|
||||
@@ -0,0 +1,31 @@
|
||||
# mobile — app CHLOVA (React Native / Expo)
|
||||
|
||||
Client natif iOS/Android, **réutilise l'API du backend** (mêmes endpoints que le
|
||||
web). Expo SDK 56 + expo-router. Thème dark HUD (voir `../docs/ui-design.md`).
|
||||
|
||||
## Lancer
|
||||
```bash
|
||||
npm install
|
||||
# URL du backend (HTTPS, derrière Traefik) :
|
||||
export EXPO_PUBLIC_API_BASE=https://chlova.example.com # PowerShell: $env:EXPO_PUBLIC_API_BASE=...
|
||||
npm start # puis 'a' (Android) / 'i' (iOS) / QR Expo Go
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Écrans
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `app/login.tsx` | Login fort (mot de passe + TOTP). |
|
||||
| `app/(tabs)/chat.tsx` | Conversation agent + TTS (expo-speech). |
|
||||
| `app/(tabs)/review.tsx` | Need-review : approuver / refuser (confirm natif). |
|
||||
| `src/api.ts` | Client API (base = `EXPO_PUBLIC_API_BASE`). |
|
||||
| `src/auth.tsx` | JWT en `expo-secure-store` (Keychain/Keystore). |
|
||||
|
||||
## Notes
|
||||
- **STT** (dictée) non inclus en v1 (nécessite une lib native dédiée) ; **TTS** OK
|
||||
via `expo-speech`. La voix complète existe déjà côté web.
|
||||
- Versions des libs gérées par **Expo** (`npx expo install`), pas en épinglage
|
||||
exact (norme de l'écosystème RN/Expo).
|
||||
- `npm audit` : ~10 alertes modérées dans la **chaîne de build Expo CLI**
|
||||
(`xcode`/`uuid`, prebuild dev-time) — pas dans le bundle livré ; non corrigeables
|
||||
sans casser le SDK.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "CHLOVA",
|
||||
"slug": "chlova",
|
||||
"scheme": "chlova",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#020617",
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
"expo-status-bar"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": false
|
||||
},
|
||||
"extra": {
|
||||
"router": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Tabs, Redirect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "@/auth";
|
||||
import { C } from "@/theme";
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { token, ready } = useAuth();
|
||||
if (ready && !token) return <Redirect href="/login" />;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: C.surface },
|
||||
headerTintColor: C.accent,
|
||||
tabBarStyle: { backgroundColor: C.surface, borderTopColor: C.border },
|
||||
tabBarActiveTintColor: C.accent,
|
||||
tabBarInactiveTintColor: C.muted,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="chat"
|
||||
options={{
|
||||
title: "Chat",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubble-ellipses" color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="review"
|
||||
options={{
|
||||
title: "Review",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="shield-checkmark" color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, ScrollView, StyleSheet } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Speech from "expo-speech";
|
||||
import { useAuth } from "@/auth";
|
||||
import { api, ApiError } from "@/api";
|
||||
import { C } from "@/theme";
|
||||
|
||||
interface Msg {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const { token, logout } = useAuth();
|
||||
const [messages, setMessages] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [speak, setSpeak] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scroll = useRef<ScrollView>(null);
|
||||
|
||||
const send = async (): Promise<void> => {
|
||||
const t = input.trim();
|
||||
if (!t || busy || !token) return;
|
||||
setInput("");
|
||||
setError(null);
|
||||
setMessages((m) => [...m, { role: "user", text: t }]);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { reply } = await api.chat(token, t);
|
||||
setMessages((m) => [...m, { role: "assistant", text: reply }]);
|
||||
if (speak) Speech.speak(reply, { language: "fr-FR" });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.wrap}>
|
||||
<ScrollView
|
||||
ref={scroll}
|
||||
style={s.list}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
{messages.length === 0 && <Text style={s.muted}>Pose une question à CHLOVA…</Text>}
|
||||
{messages.map((m, i) => (
|
||||
<View key={i} style={[s.bubble, m.role === "user" ? s.user : s.assistant]}>
|
||||
<Text style={s.bubbleText}>{m.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
{busy && <Text style={s.muted}>CHLOVA réfléchit…</Text>}
|
||||
{error && <Text style={s.error}>{error}</Text>}
|
||||
</ScrollView>
|
||||
|
||||
<View style={s.bar}>
|
||||
<Pressable onPress={() => setSpeak((v) => !v)} style={s.iconBtn} accessibilityLabel="Voix">
|
||||
<Ionicons name={speak ? "volume-high" : "volume-mute"} size={20} color={speak ? C.accent : C.muted} />
|
||||
</Pressable>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
placeholder="Message…"
|
||||
placeholderTextColor={C.muted}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
editable={!busy}
|
||||
/>
|
||||
<Pressable onPress={send} disabled={busy || !input.trim()} style={[s.send, (busy || !input.trim()) && s.disabled]}>
|
||||
<Ionicons name="send" size={18} color={C.bg} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { flex: 1, backgroundColor: C.bg },
|
||||
list: { flex: 1 },
|
||||
muted: { color: C.muted, fontSize: 13 },
|
||||
error: { color: C.danger, fontSize: 13 },
|
||||
bubble: { maxWidth: "85%", borderRadius: 10, padding: 10, borderWidth: 1 },
|
||||
user: { alignSelf: "flex-end", backgroundColor: C.surface2, borderColor: C.accent },
|
||||
assistant: { alignSelf: "flex-start", backgroundColor: C.surface, borderColor: C.border },
|
||||
bubbleText: { color: C.fg, fontSize: 14 },
|
||||
bar: { flexDirection: "row", alignItems: "center", gap: 8, padding: 10, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.surface },
|
||||
iconBtn: { padding: 8, borderRadius: 8, borderWidth: 1, borderColor: C.border },
|
||||
input: { flex: 1, backgroundColor: C.surface2, borderColor: C.border, borderWidth: 1, borderRadius: 8, color: C.fg, paddingHorizontal: 12, paddingVertical: 8 },
|
||||
send: { backgroundColor: C.accent, borderRadius: 8, padding: 10 },
|
||||
disabled: { opacity: 0.5 },
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, Pressable, FlatList, StyleSheet, Alert } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "@/auth";
|
||||
import { api, ApiError, type Asset } from "@/api";
|
||||
import { C } from "@/theme";
|
||||
|
||||
export default function Review() {
|
||||
const { token, logout } = useAuth();
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onErr = useCallback(
|
||||
(err: unknown): void => {
|
||||
if (err instanceof ApiError && err.status === 401) void logout();
|
||||
else setError(err instanceof Error ? err.message : "Erreur");
|
||||
},
|
||||
[logout],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { assets } = await api.review(token);
|
||||
setAssets(assets);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
onErr(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, onErr]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const decide = async (id: string, action: "approve" | "refuse"): Promise<void> => {
|
||||
if (!token) return;
|
||||
try {
|
||||
if (action === "approve") await api.approve(token, id);
|
||||
else await api.refuse(token, id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
onErr(err);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRefuse = (id: string): void => {
|
||||
Alert.alert("Refuser", `Refuser définitivement ${id} ?`, [
|
||||
{ text: "Annuler", style: "cancel" },
|
||||
{ text: "Refuser", style: "destructive", onPress: () => void decide(id, "refuse") },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.wrap}>
|
||||
<FlatList
|
||||
data={assets}
|
||||
keyExtractor={(a) => a.id}
|
||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
||||
onRefresh={() => void refresh()}
|
||||
refreshing={loading}
|
||||
ListEmptyComponent={!loading ? <Text style={s.muted}>Aucun asset en attente.</Text> : null}
|
||||
ListHeaderComponent={error ? <Text style={s.error}>{error}</Text> : null}
|
||||
renderItem={({ item: a }) => (
|
||||
<View style={s.card}>
|
||||
<Text style={s.id}>{a.id}</Text>
|
||||
<Text style={[s.tier, { color: a.riskTier === "privileged" ? C.danger : C.success }]}>
|
||||
{a.riskTier} · {a.status} · v{a.version}
|
||||
</Text>
|
||||
<View style={s.actions}>
|
||||
<Pressable style={[s.act, s.approve]} onPress={() => void decide(a.id, "approve")}>
|
||||
<Ionicons name="checkmark" size={16} color={C.success} />
|
||||
<Text style={[s.actText, { color: C.success }]}>Approuver</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[s.act, s.refuse]} onPress={() => confirmRefuse(a.id)}>
|
||||
<Ionicons name="close" size={16} color={C.danger} />
|
||||
<Text style={[s.actText, { color: C.danger }]}>Refuser</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
<Pressable style={s.logout} onPress={() => void logout()}>
|
||||
<Ionicons name="log-out-outline" size={18} color={C.muted} />
|
||||
<Text style={s.muted}> Déconnexion</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { flex: 1, backgroundColor: C.bg },
|
||||
muted: { color: C.muted, fontSize: 13 },
|
||||
error: { color: C.danger, fontSize: 13, marginBottom: 8 },
|
||||
card: { backgroundColor: C.surface, borderColor: C.border, borderWidth: 1, borderRadius: 10, padding: 12, gap: 6 },
|
||||
id: { color: C.fg, fontFamily: "monospace", fontSize: 13 },
|
||||
tier: { fontSize: 12 },
|
||||
actions: { flexDirection: "row", gap: 8, marginTop: 4 },
|
||||
act: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 },
|
||||
approve: { borderColor: C.success },
|
||||
refuse: { borderColor: C.danger },
|
||||
actText: { fontSize: 13 },
|
||||
logout: { flexDirection: "row", alignItems: "center", justifyContent: "center", padding: 12, borderTopWidth: 1, borderTopColor: C.border },
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { AuthProvider } from "@/auth";
|
||||
import { C } from "@/theme";
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: C.bg },
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Redirect } from "expo-router";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useAuth } from "@/auth";
|
||||
import { C } from "@/theme";
|
||||
|
||||
export default function Index() {
|
||||
const { token, ready } = useAuth();
|
||||
if (!ready) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", backgroundColor: C.bg }}>
|
||||
<ActivityIndicator color={C.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <Redirect href={token ? "/(tabs)/chat" : "/login"} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAuth } from "@/auth";
|
||||
import { ApiError } from "@/api";
|
||||
import { C } from "@/theme";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(user, password, totp);
|
||||
router.replace("/(tabs)/chat");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError && err.status === 429 ? "Trop de tentatives." : "Identifiants invalides.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.wrap}>
|
||||
<Text style={s.title}>CHLOVA</Text>
|
||||
<Text style={s.sub}>Accès propriétaire — authentification forte.</Text>
|
||||
<TextInput style={s.input} placeholder="Utilisateur" placeholderTextColor={C.muted} autoCapitalize="none" value={user} onChangeText={setUser} />
|
||||
<TextInput style={s.input} placeholder="Mot de passe" placeholderTextColor={C.muted} secureTextEntry value={password} onChangeText={setPassword} />
|
||||
<TextInput style={s.input} placeholder="Code 2FA (TOTP)" placeholderTextColor={C.muted} keyboardType="number-pad" value={totp} onChangeText={setTotp} />
|
||||
{error && <Text style={s.error}>{error}</Text>}
|
||||
<Pressable style={[s.btn, busy && s.btnDisabled]} onPress={submit} disabled={busy}>
|
||||
<Text style={s.btnText}>{busy ? "Connexion…" : "Se connecter"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { flex: 1, justifyContent: "center", padding: 24, gap: 12, backgroundColor: C.bg },
|
||||
title: { color: C.accent, fontSize: 32, fontWeight: "700", letterSpacing: 2 },
|
||||
sub: { color: C.muted, marginBottom: 8 },
|
||||
input: { backgroundColor: C.surface2, borderColor: C.border, borderWidth: 1, borderRadius: 8, color: C.fg, padding: 12 },
|
||||
error: { color: C.danger },
|
||||
btn: { backgroundColor: C.accent, borderRadius: 8, padding: 14, alignItems: "center", marginTop: 4 },
|
||||
btnDisabled: { opacity: 0.5 },
|
||||
btnText: { color: C.bg, fontWeight: "600" },
|
||||
});
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference types="expo/types" />
|
||||
|
||||
// NOTE: Ce fichier ne doit pas être édité et ne doit pas être versionné dans git
|
||||
// normalement, mais on le garde ici pour permettre le typecheck hors `expo start`.
|
||||
Generated
+7735
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "chlova-mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"description": "CHLOVA — app mobile (React Native / Expo), client du backend",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"expo": "56.0.12",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-speech": "~56.0.3",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"react": "19.2.7",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.17",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Client de l'API CHLOVA (mobile). Le backend est distant : l'URL de base est
|
||||
// fournie par EXPO_PUBLIC_API_BASE (ex. https://chlova.example.com).
|
||||
|
||||
const BASE = process.env.EXPO_PUBLIC_API_BASE ?? "";
|
||||
|
||||
export interface Asset {
|
||||
id: string;
|
||||
type: string;
|
||||
version: string;
|
||||
riskTier: string;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
execCount: number;
|
||||
commitLink: string | null;
|
||||
docLink: string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function req<T>(path: string, token: string | null, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers as Record<string, string>) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (user: string, password: string, totp: string) =>
|
||||
req<{ token: string }>("/auth/login", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user, password, totp }),
|
||||
}),
|
||||
chat: (token: string, message: string) =>
|
||||
req<{ reply: string }>("/chat", token, { method: "POST", body: JSON.stringify({ message }) }),
|
||||
review: (token: string) => req<{ assets: Asset[] }>("/review", token),
|
||||
approve: (token: string, id: string) =>
|
||||
req<{ asset: Asset }>(`/review/${encodeURIComponent(id)}/approve`, token, { method: "POST" }),
|
||||
refuse: (token: string, id: string) =>
|
||||
req<{ asset: Asset }>(`/review/${encodeURIComponent(id)}/refuse`, token, { method: "POST" }),
|
||||
state: (token: string) => req<{ phase: string; tools: number }>("/state", token),
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { api } from "./api";
|
||||
|
||||
/**
|
||||
* Auth mobile : JWT stocké de façon sécurisée (Keychain/Keystore via
|
||||
* expo-secure-store). Owner unique, login fort (mdp + TOTP) côté backend.
|
||||
*/
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
ready: boolean;
|
||||
login: (user: string, password: string, totp: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const KEY = "chlova.token";
|
||||
const Ctx = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void SecureStore.getItemAsync(KEY).then((t) => {
|
||||
setToken(t);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = async (user: string, password: string, totp: string): Promise<void> => {
|
||||
const { token: t } = await api.login(user, password, totp);
|
||||
await SecureStore.setItemAsync(KEY, t);
|
||||
setToken(t);
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
await SecureStore.deleteItemAsync(KEY);
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={{ token, ready, login, logout }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useAuth hors AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Tokens CHLOVA (dark HUD "Jarvis-red") — alignés sur docs/ui-design.md.
|
||||
export const C = {
|
||||
bg: "#020617",
|
||||
surface: "#0f172a",
|
||||
surface2: "#1e293b",
|
||||
border: "#334155",
|
||||
fg: "#f8fafc",
|
||||
muted: "#94a3b8",
|
||||
accent: "#ff3b3b",
|
||||
danger: "#b91c1c",
|
||||
success: "#22c55e",
|
||||
warning: "#f59e0b",
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
+22
-8
@@ -1,21 +1,35 @@
|
||||
# CHLOVA backend — image multi-stage, base épinglée (jamais :latest).
|
||||
# Contexte de build = RACINE du dépôt (voir infra/docker-compose.yml) : l'image
|
||||
# embarque l'API ET le SPA web (servi same-origin).
|
||||
# TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel.
|
||||
|
||||
FROM node:24.13-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
# ── Build du SPA (web/) ─────────────────────────────────────────────────
|
||||
FROM node:24.13-bookworm-slim AS web-build
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json tsconfig.build.json ./
|
||||
COPY src ./src
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Build de l'API (orchestrator/) ──────────────────────────────────────
|
||||
FROM node:24.13-bookworm-slim AS api-build
|
||||
WORKDIR /app
|
||||
COPY orchestrator/package.json orchestrator/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY orchestrator/tsconfig.json orchestrator/tsconfig.build.json ./
|
||||
COPY orchestrator/src ./src
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime ─────────────────────────────────────────────────────────────
|
||||
FROM node:24.13-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
ENV CHLOVA_WEB_ROOT=/app/web
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY orchestrator/package.json orchestrator/package-lock.json* ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
# Données runtime (SQLite, P2+). L'utilisateur node ne tourne pas en root.
|
||||
COPY --from=api-build /app/dist ./dist
|
||||
COPY --from=web-build /web/dist ./web
|
||||
# Données runtime (SQLite). L'utilisateur node ne tourne pas en root.
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
|
||||
Generated
+165
-2
@@ -8,8 +8,9 @@
|
||||
"name": "chlova-orchestrator",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/rate-limit": "^11.0.0",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/rate-limit": "11.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"fastify": "5.8.5",
|
||||
"jose": "6.2.3",
|
||||
@@ -503,6 +504,22 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/accept-negotiator": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
|
||||
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
||||
@@ -655,6 +672,53 @@
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/send": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"fast-decode-uri-component": "^1.0.1",
|
||||
"http-errors": "^2.0.0",
|
||||
"mime": "^3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/static": {
|
||||
"version": "9.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
|
||||
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/accept-negotiator": "^2.0.0",
|
||||
"@fastify/send": "^4.0.0",
|
||||
"content-disposition": "^1.0.1",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"fastq": "^1.17.1",
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
@@ -1392,6 +1456,15 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
@@ -1429,6 +1502,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -2076,6 +2161,23 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -2556,6 +2658,15 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2596,6 +2707,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -2621,6 +2744,30 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2752,6 +2899,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/rate-limit": "11.0.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"fastify": "5.8.5",
|
||||
"jose": "6.2.3",
|
||||
|
||||
@@ -24,7 +24,13 @@ const PHASE_2 = `PHASE ACTUELLE : ÉCRITURE SOUS REVIEW. Tu peux proposer des ac
|
||||
mutantes (déploiement, modification). Toute action privilégiée est BLOQUÉE par le
|
||||
gatekeeper jusqu'à validation humaine : à la première tentative, elle est refusée
|
||||
et mise en attente de review. N'affirme jamais qu'une action mutante a réussi tant
|
||||
que l'outil ne l'a pas confirmée. Les lectures restent libres.`;
|
||||
que l'outil ne l'a pas confirmée. Les lectures restent libres.
|
||||
|
||||
Si AUCUNE capacité existante ne convient et que l'outil chlova.propose_asset est
|
||||
disponible, tu peux proposer un nouvel asset (workflow n8n ou outil) : il sera
|
||||
écrit, versionné, documenté et mis EN REVIEW (un asset privilégié reste bloqué
|
||||
jusqu'à validation humaine). Classe honnêtement le palier de risque ; ne sous-
|
||||
estime jamais un asset privilégié pour contourner la review.`;
|
||||
|
||||
export function buildSystemPrompt(phase: 1 | 2): string {
|
||||
return `${COMMON}\n\n${phase === 2 ? PHASE_2 : PHASE_1}`;
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface ToolSpec {
|
||||
readOnly: boolean;
|
||||
/** Palier de risque résolu (voir docs/risk-tiers.md). */
|
||||
riskTier: RiskTier;
|
||||
/**
|
||||
* Outil interne SANCTIONNÉ de CHLOVA (ex. propose_asset). Ne touche pas
|
||||
* l'infra : il STAGE des propositions reviewables (l'asset produit reste gated).
|
||||
* Autorisé par le gatekeeper mais toujours audité. Jamais issu de MCP.
|
||||
*/
|
||||
sanctioned?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolHandle {
|
||||
|
||||
@@ -37,6 +37,16 @@ export type AlertEvent =
|
||||
blocked: number;
|
||||
provisional: number;
|
||||
items: DigestItem[];
|
||||
}
|
||||
| {
|
||||
kind: "asset_created";
|
||||
assetId: string;
|
||||
assetType: string;
|
||||
version: string;
|
||||
riskTier: string;
|
||||
status: string;
|
||||
commit: string;
|
||||
doc: string;
|
||||
};
|
||||
|
||||
export interface AlertSender {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, dirname, isAbsolute, normalize } from "node:path";
|
||||
import type { RiskTier } from "../audit/log.js";
|
||||
|
||||
/**
|
||||
* Écriture des artefacts auto-créés par CHLOVA (Phase 5).
|
||||
*
|
||||
* Le dépôt fait foi : un asset auto-créé est écrit en fichier versionné + une doc
|
||||
* générée depuis le gabarit AVANT d'être enregistré en need-review (voir
|
||||
* docs/versioning.md, docs/need-review.md). Chemins sanitizés (anti-traversée).
|
||||
*/
|
||||
|
||||
export type AutoAssetType = "workflow-n8n" | "tool";
|
||||
|
||||
export interface AssetDraft {
|
||||
type: AutoAssetType;
|
||||
name: string;
|
||||
version: string; // semver
|
||||
riskTier: RiskTier;
|
||||
summary: string;
|
||||
/** Contenu : JSON du workflow n8n, ou définition d'outil. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
const SEMVER = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
export function slugify(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (!slug) throw new Error("nom d'asset invalide (slug vide)");
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function artifactRelPath(draft: AssetDraft): string {
|
||||
const slug = slugify(draft.name);
|
||||
if (draft.type === "workflow-n8n") return `workflows-n8n/${slug}.v${draft.version}.json`;
|
||||
return `tools/${slug}.v${draft.version}.json`;
|
||||
}
|
||||
|
||||
export function docRelPath(draft: AssetDraft): string {
|
||||
return `docs/assets/${draft.type}-${slugify(draft.name)}.md`;
|
||||
}
|
||||
|
||||
/** Génère la doc d'asset depuis le gabarit (docs/asset-template.md). */
|
||||
export function renderDoc(draft: AssetDraft, artifactPath: string, createdAt: string): string {
|
||||
return `## ${draft.name}
|
||||
|
||||
- **Type** : \`${draft.type}\`
|
||||
- **Version** : \`v${draft.version}\`
|
||||
- **Palier de risque** : \`${draft.riskTier}\`
|
||||
- **Statut (need-review)** : \`${draft.riskTier === "reversible" ? "provisoire" : "bloqué"}\`
|
||||
- **Créé le** : ${createdAt} (auto-extension CHLOVA)
|
||||
- **Fichier** : \`${artifactPath}\`
|
||||
|
||||
### Rôle
|
||||
${draft.summary}
|
||||
|
||||
### Sécurité / rollback
|
||||
Asset auto-créé par CHLOVA, en attente de review. Palier \`${draft.riskTier}\` :
|
||||
${draft.riskTier === "privileged"
|
||||
? "BLOQUÉ jusqu'à validation humaine (aucun sursis)."
|
||||
: "PROVISOIRE, sursis de 7 jours."}
|
||||
Rollback : \`git revert\` du commit, ou refus via la review (/refuse).
|
||||
`;
|
||||
}
|
||||
|
||||
function assertSafe(repoRoot: string, rel: string): void {
|
||||
if (isAbsolute(rel) || normalize(rel).startsWith("..")) {
|
||||
throw new Error(`chemin non autorisé: ${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit l'artefact + sa doc dans le dépôt. Retourne les chemins relatifs (servent
|
||||
* de doc_link et de cible de commit). Ne commite PAS (cf. GitCommitter).
|
||||
*/
|
||||
export async function writeArtifact(
|
||||
repoRoot: string,
|
||||
draft: AssetDraft,
|
||||
): Promise<{ artifactPath: string; docPath: string }> {
|
||||
if (!SEMVER.test(draft.version)) throw new Error(`version semver invalide: ${draft.version}`);
|
||||
const artifactPath = artifactRelPath(draft);
|
||||
const docPath = docRelPath(draft);
|
||||
assertSafe(repoRoot, artifactPath);
|
||||
assertSafe(repoRoot, docPath);
|
||||
|
||||
const createdAt = new Date().toISOString().slice(0, 10);
|
||||
const doc = renderDoc(draft, artifactPath, createdAt);
|
||||
|
||||
for (const [rel, body] of [
|
||||
[artifactPath, draft.content],
|
||||
[docPath, doc],
|
||||
] as const) {
|
||||
const abs = join(repoRoot, rel);
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
await writeFile(abs, body, "utf8");
|
||||
}
|
||||
|
||||
return { artifactPath, docPath };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Logger } from "pino";
|
||||
import { AssetRepository } from "../gatekeeper/repository.js";
|
||||
import { createAsset, type Asset } from "../gatekeeper/assets.js";
|
||||
import type { AlertSender } from "../alerts/types.js";
|
||||
import { writeArtifact, slugify, type AssetDraft } from "./artifact-writer.js";
|
||||
import { GitCommitter } from "./git-committer.js";
|
||||
|
||||
/**
|
||||
* Auto-extension (Phase 5) : CHLOVA crée un asset → écrit fichier + doc →
|
||||
* COMMIT + VERSION → enregistre en need-review → alerte (avec version + commit +
|
||||
* doc). L'asset n'est PAS exécuté : il attend la review (privilégié = BLOQUÉ).
|
||||
*
|
||||
* Désactivable (CHLOVA_AUTOEXT_ENABLED). Le palier vient du draft, jamais d'un
|
||||
* choix du LLM visant à contourner la review (privilégié → aucun sursis).
|
||||
*/
|
||||
export interface ProposeResult {
|
||||
asset: Asset;
|
||||
commit: string;
|
||||
docPath: string;
|
||||
artifactPath: string;
|
||||
}
|
||||
|
||||
export class AutoExtensionService {
|
||||
constructor(
|
||||
private readonly repo: AssetRepository,
|
||||
private readonly git: GitCommitter,
|
||||
private readonly alerts: AlertSender,
|
||||
private readonly logger: Logger,
|
||||
private readonly repoRoot: string,
|
||||
) {}
|
||||
|
||||
async propose(draft: AssetDraft): Promise<ProposeResult> {
|
||||
const id = `asset:${draft.type}:${slugify(draft.name)}:v${draft.version}`;
|
||||
if (this.repo.get(id)) {
|
||||
throw new Error(`asset déjà existant: ${id} (bump la version)`);
|
||||
}
|
||||
|
||||
const { artifactPath, docPath } = await writeArtifact(this.repoRoot, draft);
|
||||
const commit = await this.git.commitPaths(
|
||||
[artifactPath, docPath],
|
||||
`feat(auto): ${draft.type} ${draft.name} v${draft.version} [need-review]`,
|
||||
);
|
||||
|
||||
const asset = createAsset({
|
||||
id,
|
||||
type: draft.type,
|
||||
version: draft.version,
|
||||
riskTier: draft.riskTier,
|
||||
commitLink: commit,
|
||||
docLink: docPath,
|
||||
});
|
||||
this.repo.create(asset);
|
||||
|
||||
await this.alerts.send({
|
||||
kind: "asset_created",
|
||||
assetId: id,
|
||||
assetType: draft.type,
|
||||
version: draft.version,
|
||||
riskTier: draft.riskTier,
|
||||
status: asset.status,
|
||||
commit,
|
||||
doc: docPath,
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
{ asset: id, commit, status: asset.status },
|
||||
"auto-extension : asset créé en need-review",
|
||||
);
|
||||
return { asset, commit, docPath, artifactPath };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Commits ciblés pour l'auto-extension (Phase 5). N'ajoute QUE les chemins
|
||||
* fournis (jamais `git add -A`) et commite dans le dépôt local. Retourne le SHA.
|
||||
*
|
||||
* Le commit matérialise « le dépôt fait foi » : un asset auto-créé est versionné
|
||||
* AVANT d'être passé en need-review (docs/versioning.md).
|
||||
*/
|
||||
export class GitCommitter {
|
||||
constructor(
|
||||
private readonly repoRoot: string,
|
||||
private readonly author = "CHLOVA <chlova@local>",
|
||||
) {}
|
||||
|
||||
private async git(args: string[]): Promise<string> {
|
||||
const { stdout } = await exec("git", args, { cwd: this.repoRoot });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/** Ajoute les chemins, commite avec le message, renvoie le SHA court. */
|
||||
async commitPaths(paths: string[], message: string): Promise<string> {
|
||||
if (paths.length === 0) throw new Error("aucun chemin à committer");
|
||||
await this.git(["add", "--", ...paths]);
|
||||
const [name, email] = parseAuthor(this.author);
|
||||
await this.git([
|
||||
"-c",
|
||||
`user.name=${name}`,
|
||||
"-c",
|
||||
`user.email=${email}`,
|
||||
"commit",
|
||||
"-m",
|
||||
message,
|
||||
"--",
|
||||
...paths,
|
||||
]);
|
||||
return this.git(["rev-parse", "--short", "HEAD"]);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAuthor(author: string): [string, string] {
|
||||
const m = /^(.*?)\s*<(.+)>$/.exec(author);
|
||||
return m ? [m[1]!, m[2]!] : [author, "chlova@local"];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ToolHandle } from "../agent/types.js";
|
||||
import type { AutoExtensionService } from "./auto-extension.js";
|
||||
import type { AssetDraft, AutoAssetType } from "./artifact-writer.js";
|
||||
|
||||
/**
|
||||
* Outil local `chlova.propose_asset` exposé à l'agent (Phase 5).
|
||||
*
|
||||
* Quand aucune capacité existante ne suffit, l'agent propose un nouvel asset
|
||||
* (workflow n8n ou outil). L'outil écrit + commit + versionne + documente, puis
|
||||
* enregistre l'asset en NEED-REVIEW. Il n'EXÉCUTE jamais l'asset. Sanctionné
|
||||
* (autorisé par le gatekeeper) mais audité.
|
||||
*/
|
||||
export function buildProposeAssetTool(service: AutoExtensionService): ToolHandle {
|
||||
return {
|
||||
spec: {
|
||||
name: "chlova.propose_asset",
|
||||
description:
|
||||
"Propose un nouvel asset (workflow n8n ou outil) quand rien d'existant " +
|
||||
"ne convient. Écrit + commit + versionne + documente, puis met en " +
|
||||
"need-review (un asset privilégié reste BLOQUÉ jusqu'à validation humaine). " +
|
||||
"N'exécute jamais l'asset.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["type", "name", "version", "riskTier", "summary", "content"],
|
||||
properties: {
|
||||
type: { type: "string", enum: ["workflow-n8n", "tool"] },
|
||||
name: { type: "string", description: "Nom lisible de l'asset." },
|
||||
version: { type: "string", description: "SemVer, ex. 1.0.0." },
|
||||
riskTier: {
|
||||
type: "string",
|
||||
enum: ["reversible", "privileged"],
|
||||
description:
|
||||
"privileged si l'asset déploie/supprime/accède aux secrets/exécute ; sinon reversible.",
|
||||
},
|
||||
summary: { type: "string", description: "Rôle de l'asset (1-3 phrases)." },
|
||||
content: {
|
||||
type: "string",
|
||||
description: "Contenu : JSON du workflow n8n, ou définition de l'outil.",
|
||||
},
|
||||
},
|
||||
},
|
||||
server: "chlova",
|
||||
readOnly: false,
|
||||
riskTier: "privileged",
|
||||
sanctioned: true,
|
||||
},
|
||||
async execute(args: Record<string, unknown>): Promise<string> {
|
||||
const draft: AssetDraft = {
|
||||
type: args.type as AutoAssetType,
|
||||
name: String(args.name ?? ""),
|
||||
version: String(args.version ?? ""),
|
||||
riskTier: args.riskTier === "reversible" ? "reversible" : "privileged",
|
||||
summary: String(args.summary ?? ""),
|
||||
content: String(args.content ?? ""),
|
||||
};
|
||||
const res = await service.propose(draft);
|
||||
return (
|
||||
`Asset créé en need-review : ${res.asset.id}\n` +
|
||||
`Statut : ${res.asset.status} (palier ${res.asset.riskTier})\n` +
|
||||
`Commit : ${res.commit} · Doc : ${res.docPath}\n` +
|
||||
(res.asset.status === "bloqué"
|
||||
? "Privilégié → BLOQUÉ jusqu'à validation humaine (/approve)."
|
||||
: "Provisoire → exécutable, sursis 7 jours.")
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -45,8 +45,10 @@ const schema = z.object({
|
||||
.default("true")
|
||||
.transform((v) => v.toLowerCase() !== "false"),
|
||||
|
||||
// Surface Telegram
|
||||
telegramBotToken: nonEmpty, // SECRET
|
||||
// Surface Telegram (OPTIONNELLE) : si le token est absent, la surface n'est
|
||||
// pas démarrée. Le boot exige alors une autre surface (API/UI) — voir
|
||||
// assertHasSurface(). Permet un déploiement UI-only sans bot Telegram.
|
||||
telegramBotToken: z.string().optional(), // SECRET
|
||||
telegramAllowedUserIds: z
|
||||
.string()
|
||||
.default("")
|
||||
@@ -78,6 +80,16 @@ const schema = z.object({
|
||||
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
|
||||
z.string().url().optional(),
|
||||
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin
|
||||
// Racine du SPA buildé, servi same-origin (vide = ne sert pas de SPA).
|
||||
webRoot: z.string().optional(),
|
||||
|
||||
// Auto-extension (Phase 5) : l'agent peut créer des assets en need-review.
|
||||
// Désactivé par défaut (fail-safe). Requiert un dépôt git à la racine.
|
||||
autoextEnabled: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((v) => v.toLowerCase() === "true"),
|
||||
repoRoot: z.string().default("."),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof schema>;
|
||||
@@ -116,6 +128,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
totpSecret: env.CHLOVA_TOTP_SECRET,
|
||||
jwtSecret: env.CHLOVA_JWT_SECRET,
|
||||
webOrigin: env.CHLOVA_WEB_ORIGIN,
|
||||
webRoot: env.CHLOVA_WEB_ROOT,
|
||||
autoextEnabled: env.CHLOVA_AUTOEXT_ENABLED,
|
||||
repoRoot: env.CHLOVA_REPO_ROOT,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -149,6 +164,21 @@ export function assertReadOnlyPhase(cfg: Config): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Au moins une surface doit être active, sinon le cerveau tourne sans entrée :
|
||||
* démarrage refusé (fail-closed). Surfaces possibles : Telegram (token présent)
|
||||
* ou API/UI (auth complète). Évite un déploiement « muet » par mégarde.
|
||||
*/
|
||||
export function assertHasSurface(cfg: Config): void {
|
||||
if (!cfg.telegramBotToken && !apiAuth(cfg)) {
|
||||
throw new Error(
|
||||
"Aucune surface configurée : fournis TELEGRAM_BOT_TOKEN (bot) " +
|
||||
"ou les 4 variables API/UI (CHLOVA_ADMIN_USER/_PASSWORD_HASH/" +
|
||||
"_TOTP_SECRET/_JWT_SECRET). Voir docs/deploy.md.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Config d'auth de l'API si les 4 valeurs requises sont présentes, sinon null
|
||||
* (API/UI désactivée — surface non exposée).
|
||||
|
||||
@@ -48,6 +48,11 @@ export class Gatekeeper {
|
||||
* incrément du compteur d'exécution).
|
||||
*/
|
||||
authorizeTool(spec: ToolSpec): { allowed: boolean; reason?: string } {
|
||||
// Outil interne sanctionné (ex. propose_asset) : canal contrôlé qui ne fait
|
||||
// que stager des propositions reviewables (l'asset produit reste gated).
|
||||
if (spec.sanctioned) {
|
||||
return { allowed: true };
|
||||
}
|
||||
// Lecture seule : pas d'asset, pas de review.
|
||||
if (spec.riskTier === "reversible" && spec.readOnly) {
|
||||
return { allowed: true };
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Fastify, { type FastifyBaseLogger } from "fastify";
|
||||
import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { loadConfig, assertReadOnlyPhase, assertHasSurface, redactedConfig, apiAuth } from "./config.js";
|
||||
import { registerApi } from "./api/routes.js";
|
||||
import { createLogger } from "./audit/log.js";
|
||||
import { OllamaClient } from "./llm/ollama.js";
|
||||
@@ -12,6 +15,9 @@ import { isCommand, handleReviewCommand } from "./surfaces/commands.js";
|
||||
import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js";
|
||||
import { startAlertScheduler } from "./alerts/scheduler.js";
|
||||
import type { AlertSender } from "./alerts/types.js";
|
||||
import { GitCommitter } from "./autoext/git-committer.js";
|
||||
import { AutoExtensionService } from "./autoext/auto-extension.js";
|
||||
import { buildProposeAssetTool } from "./autoext/tool.js";
|
||||
import type { Guard, ToolHandle } from "./agent/types.js";
|
||||
import { ChatService } from "./agent/chat-service.js";
|
||||
import { buildSystemPrompt } from "./agent/system-prompt.js";
|
||||
@@ -28,6 +34,7 @@ import { TelegramSurface } from "./surfaces/telegram.js";
|
||||
async function main(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
assertReadOnlyPhase(cfg);
|
||||
assertHasSurface(cfg);
|
||||
|
||||
const logger = createLogger(cfg.logLevel);
|
||||
logger.info({ config: redactedConfig(cfg) }, "CHLOVA backend démarrage");
|
||||
@@ -77,6 +84,15 @@ async function main(): Promise<void> {
|
||||
});
|
||||
guard = new GatekeeperGuard(gatekeeper);
|
||||
tools = await registry.listAllTools();
|
||||
|
||||
// Auto-extension (Phase 5) : ajoute l'outil sanctionné propose_asset.
|
||||
if (cfg.autoextEnabled) {
|
||||
const git = new GitCommitter(cfg.repoRoot);
|
||||
const autoext = new AutoExtensionService(repo, git, alerts, logger, cfg.repoRoot);
|
||||
tools = [...tools, buildProposeAssetTool(autoext)];
|
||||
logger.warn({ repoRoot: cfg.repoRoot }, "auto-extension ACTIVÉE (propose_asset exposé)");
|
||||
}
|
||||
|
||||
review = new ReviewService(repo, logger);
|
||||
stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire
|
||||
stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1
|
||||
@@ -94,14 +110,18 @@ async function main(): Promise<void> {
|
||||
});
|
||||
const chat = new ChatService({ client, tools, guard, systemPrompt, logger });
|
||||
|
||||
// ── Surface Telegram (long-polling) ─────────────────────────────────────
|
||||
const telegram = new TelegramSurface(
|
||||
// ── Surface Telegram (long-polling) — optionnelle ───────────────────────
|
||||
// Démarrée seulement si un token est fourni. Sinon, surface API/UI seule
|
||||
// (garantie par assertHasSurface au boot).
|
||||
const telegram = cfg.telegramBotToken
|
||||
? new TelegramSurface(
|
||||
{
|
||||
botToken: cfg.telegramBotToken,
|
||||
allowedUserIds: cfg.telegramAllowedUserIds,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
)
|
||||
: null;
|
||||
|
||||
// ── Healthcheck interne (jamais publié) ─────────────────────────────────
|
||||
// pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le
|
||||
@@ -128,12 +148,30 @@ async function main(): Promise<void> {
|
||||
logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule");
|
||||
}
|
||||
|
||||
// SPA same-origin : sert le build web + fallback (routes client) si présent.
|
||||
if (auth && cfg.webRoot) {
|
||||
const root = resolve(cfg.webRoot);
|
||||
if (existsSync(root)) {
|
||||
await app.register(fastifyStatic, { root });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
// /api et /health restent gérés ; le reste retombe sur l'app SPA.
|
||||
if (req.url.startsWith("/api") || req.url.startsWith("/health")) {
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
logger.info({ root }, "SPA servi same-origin");
|
||||
} else {
|
||||
logger.warn({ root }, "CHLOVA_WEB_ROOT introuvable — SPA non servi");
|
||||
}
|
||||
}
|
||||
|
||||
await app.listen({ host: "0.0.0.0", port: 8080 });
|
||||
logger.info({ port: 8080 }, "healthcheck interne prêt");
|
||||
|
||||
// Arrêt propre.
|
||||
const shutdown = async (): Promise<void> => {
|
||||
telegram.stop();
|
||||
telegram?.stop();
|
||||
stopCron?.();
|
||||
stopAlerts?.();
|
||||
await registry.close();
|
||||
@@ -145,12 +183,15 @@ async function main(): Promise<void> {
|
||||
process.on("SIGINT", () => void shutdown());
|
||||
|
||||
// Boucle de service : commande de review (Phase 2) ou tour d'agent.
|
||||
// Sans Telegram, le process reste vivant via le serveur Fastify (API/UI).
|
||||
if (telegram) {
|
||||
await telegram.start(async ({ userId, text }) => {
|
||||
if (review && isCommand(text)) {
|
||||
return handleReviewCommand(review, text);
|
||||
}
|
||||
return chat.handle(`telegram:${userId}`, text);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
slugify,
|
||||
artifactRelPath,
|
||||
docRelPath,
|
||||
writeArtifact,
|
||||
type AssetDraft,
|
||||
} from "../src/autoext/artifact-writer.js";
|
||||
|
||||
const draft = (over: Partial<AssetDraft> = {}): AssetDraft => ({
|
||||
type: "workflow-n8n",
|
||||
name: "Backup Nextcloud",
|
||||
version: "1.0.0",
|
||||
riskTier: "privileged",
|
||||
summary: "Sauvegarde quotidienne.",
|
||||
content: '{"name":"x"}',
|
||||
...over,
|
||||
});
|
||||
|
||||
let root: string;
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "chlova-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("slugify & chemins", () => {
|
||||
it("slugifie un nom accentué", () => {
|
||||
expect(slugify("Sauvegarde Été #2")).toBe("sauvegarde-ete-2");
|
||||
});
|
||||
|
||||
it("refuse un nom vide", () => {
|
||||
expect(() => slugify(" ")).toThrow();
|
||||
});
|
||||
|
||||
it("chemins par type", () => {
|
||||
expect(artifactRelPath(draft())).toBe("workflows-n8n/backup-nextcloud.v1.0.0.json");
|
||||
expect(artifactRelPath(draft({ type: "tool" }))).toBe("tools/backup-nextcloud.v1.0.0.json");
|
||||
expect(docRelPath(draft())).toBe("docs/assets/workflow-n8n-backup-nextcloud.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeArtifact", () => {
|
||||
it("écrit artefact + doc", async () => {
|
||||
const { artifactPath, docPath } = await writeArtifact(root, draft());
|
||||
expect(await readFile(join(root, artifactPath), "utf8")).toBe('{"name":"x"}');
|
||||
const doc = await readFile(join(root, docPath), "utf8");
|
||||
expect(doc).toContain("Backup Nextcloud");
|
||||
expect(doc).toContain("v1.0.0");
|
||||
expect(doc).toContain("bloqué"); // privilégié → bloqué
|
||||
});
|
||||
|
||||
it("refuse une version non semver", async () => {
|
||||
await expect(writeArtifact(root, draft({ version: "1.0" }))).rejects.toThrow(/semver/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtemp, rm, access } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AssetRepository } from "../src/gatekeeper/repository.js";
|
||||
import { GitCommitter } from "../src/autoext/git-committer.js";
|
||||
import { AutoExtensionService } from "../src/autoext/auto-extension.js";
|
||||
import { createLogger } from "../src/audit/log.js";
|
||||
import type { AlertSender, AlertEvent } from "../src/alerts/types.js";
|
||||
import type { AssetDraft } from "../src/autoext/artifact-writer.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const log = createLogger("silent");
|
||||
|
||||
class CapturingSender implements AlertSender {
|
||||
events: AlertEvent[] = [];
|
||||
async send(e: AlertEvent): Promise<void> {
|
||||
this.events.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
const draft = (over: Partial<AssetDraft> = {}): AssetDraft => ({
|
||||
type: "workflow-n8n",
|
||||
name: "Backup NC",
|
||||
version: "1.0.0",
|
||||
riskTier: "privileged",
|
||||
summary: "Sauvegarde.",
|
||||
content: '{"a":1}',
|
||||
...over,
|
||||
});
|
||||
|
||||
let root: string;
|
||||
let repo: AssetRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "chlova-git-"));
|
||||
await exec("git", ["init", "-q"], { cwd: root });
|
||||
await exec("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", "-m", "init"], { cwd: root });
|
||||
repo = new AssetRepository(":memory:");
|
||||
});
|
||||
afterEach(async () => {
|
||||
repo.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function service(alerts: AlertSender): AutoExtensionService {
|
||||
return new AutoExtensionService(repo, new GitCommitter(root), alerts, log, root);
|
||||
}
|
||||
|
||||
describe("AutoExtensionService.propose", () => {
|
||||
it("écrit, commit, enregistre BLOQUÉ (privilégié) et alerte", async () => {
|
||||
const sender = new CapturingSender();
|
||||
const res = await service(sender).propose(draft());
|
||||
|
||||
expect(res.asset.status).toBe("bloqué"); // privilégié → aucun sursis
|
||||
expect(res.commit).toMatch(/^[0-9a-f]{7,}$/);
|
||||
expect(res.asset.commitLink).toBe(res.commit);
|
||||
expect(res.asset.docLink).toBe(res.docPath);
|
||||
|
||||
// fichiers présents + commités
|
||||
await expect(access(join(root, res.artifactPath))).resolves.toBeUndefined();
|
||||
const { stdout } = await exec("git", ["log", "--oneline"], { cwd: root });
|
||||
expect(stdout).toContain("need-review");
|
||||
|
||||
// asset persistant + alerte
|
||||
expect(repo.get(res.asset.id)?.status).toBe("bloqué");
|
||||
expect(sender.events.map((e) => e.kind)).toContain("asset_created");
|
||||
});
|
||||
|
||||
it("réversible → PROVISOIRE", async () => {
|
||||
const res = await service(new CapturingSender()).propose(draft({ riskTier: "reversible", name: "RO tool", type: "tool" }));
|
||||
expect(res.asset.status).toBe("provisoire");
|
||||
expect(res.asset.expiresAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuse un doublon (même id)", async () => {
|
||||
const svc = service(new CapturingSender());
|
||||
await svc.propose(draft());
|
||||
await expect(svc.propose(draft())).rejects.toThrow(/existant/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AssetRepository } from "../src/gatekeeper/repository.js";
|
||||
import { Gatekeeper } from "../src/gatekeeper/gatekeeper.js";
|
||||
import { GitCommitter } from "../src/autoext/git-committer.js";
|
||||
import { AutoExtensionService } from "../src/autoext/auto-extension.js";
|
||||
import { buildProposeAssetTool } from "../src/autoext/tool.js";
|
||||
import { NullAlertSender } from "../src/alerts/sender.js";
|
||||
import { createLogger } from "../src/audit/log.js";
|
||||
import type { ToolSpec } from "../src/agent/types.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const log = createLogger("silent");
|
||||
|
||||
describe("gatekeeper autorise un outil sanctionné", () => {
|
||||
it("propose_asset (privilégié + sanctioned) est autorisé sans review", () => {
|
||||
const repo = new AssetRepository(":memory:");
|
||||
const gk = new Gatekeeper(repo, log);
|
||||
const spec: ToolSpec = {
|
||||
name: "chlova.propose_asset",
|
||||
description: "",
|
||||
parameters: {},
|
||||
server: "chlova",
|
||||
readOnly: false,
|
||||
riskTier: "privileged",
|
||||
sanctioned: true,
|
||||
};
|
||||
expect(gk.authorizeTool(spec).allowed).toBe(true);
|
||||
repo.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("propose_asset tool", () => {
|
||||
let root: string;
|
||||
let repo: AssetRepository;
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "chlova-tool-"));
|
||||
await exec("git", ["init", "-q"], { cwd: root });
|
||||
await exec("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", "-m", "init"], { cwd: root });
|
||||
repo = new AssetRepository(":memory:");
|
||||
});
|
||||
afterEach(async () => {
|
||||
repo.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("exécute la proposition et renvoie un résumé", async () => {
|
||||
const svc = new AutoExtensionService(repo, new GitCommitter(root), new NullAlertSender(log), log, root);
|
||||
const tool = buildProposeAssetTool(svc);
|
||||
expect(tool.spec.sanctioned).toBe(true);
|
||||
const out = await tool.execute({
|
||||
type: "tool",
|
||||
name: "Ping Host",
|
||||
version: "1.0.0",
|
||||
riskTier: "reversible",
|
||||
summary: "ping",
|
||||
content: "{}",
|
||||
});
|
||||
expect(out).toContain("need-review");
|
||||
expect(repo.listByStatus("provisoire")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "../src/config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
assertReadOnlyPhase,
|
||||
assertHasSurface,
|
||||
redactedConfig,
|
||||
} from "../src/config.js";
|
||||
|
||||
const fullEnv = (): NodeJS.ProcessEnv => ({
|
||||
OLLAMA_BASE_URL: "http://ollama:11434",
|
||||
@@ -61,6 +66,28 @@ describe("verrou lecture seule Phase 1", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("garde de surface (fail-closed)", () => {
|
||||
it("Telegram seul suffit", () => {
|
||||
expect(() => assertHasSurface(loadConfig(fullEnv()))).not.toThrow();
|
||||
});
|
||||
|
||||
it("API/UI seule suffit (sans Telegram)", () => {
|
||||
const env = fullEnv();
|
||||
delete env.TELEGRAM_BOT_TOKEN;
|
||||
env.CHLOVA_ADMIN_USER = "kantin";
|
||||
env.CHLOVA_ADMIN_PASSWORD_HASH = "hash";
|
||||
env.CHLOVA_TOTP_SECRET = "totp";
|
||||
env.CHLOVA_JWT_SECRET = "jwt";
|
||||
expect(() => assertHasSurface(loadConfig(env))).not.toThrow();
|
||||
});
|
||||
|
||||
it("refuse de démarrer sans aucune surface", () => {
|
||||
const env = fullEnv();
|
||||
delete env.TELEGRAM_BOT_TOKEN;
|
||||
expect(() => assertHasSurface(loadConfig(env))).toThrow(/surface/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactedConfig masque les secrets", () => {
|
||||
it("ne révèle aucun secret", () => {
|
||||
const red = redactedConfig(loadConfig(fullEnv()));
|
||||
|
||||
+11
-2
@@ -23,5 +23,14 @@ Le backend doit tourner avec l'auth configurée (`CHLOVA_ADMIN_*`, voir
|
||||
| `src/pages/Chat.tsx` | Conversation agent (v0.23.0). |
|
||||
| `src/pages/Review.tsx` | Need-review : approuver/refuser (v0.24.0). |
|
||||
|
||||
## Périmètre v1
|
||||
Login → Chat → Review. Voix + app RN : phases ultérieures (API commune réutilisée).
|
||||
## Voix (Phase 6)
|
||||
100 % navigateur (Web Speech API), zéro backend/GPU :
|
||||
- **Parler** : dictée push-to-talk (fr-FR) → envoyée à l'agent.
|
||||
- **Voix ON/OFF** : lecture vocale des réponses (TTS), réglage persistant.
|
||||
- **Libre** : mains-libres, déclenché par le wake-word « CHLOVA … » ; le micro se
|
||||
met en pause pendant la synthèse pour éviter l'auto-écoute.
|
||||
|
||||
STT = Chrome/Edge (webkit) ; TTS = large support. Dégrade proprement sinon.
|
||||
|
||||
## Périmètre
|
||||
Login → Chat (+ voix) → Review. App RN : phase ultérieure (API commune réutilisée).
|
||||
|
||||
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "chlova-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.21.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-router-dom": "7.18.0"
|
||||
@@ -4939,6 +4940,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
|
||||
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "1.21.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-router-dom": "7.18.0"
|
||||
|
||||
+25
-6
@@ -1,24 +1,37 @@
|
||||
import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom";
|
||||
import { MessageSquare, ShieldCheck, LogOut, Cpu } from "lucide-react";
|
||||
import { useAuth } from "./auth";
|
||||
import { AppDataProvider, useAppData } from "./appdata";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Chat } from "./pages/Chat";
|
||||
import { Review } from "./pages/Review";
|
||||
|
||||
function Shell() {
|
||||
const { logout } = useAuth();
|
||||
const { phase, tools, assets } = useAppData();
|
||||
const link = ({ isActive }: { isActive: boolean }): string =>
|
||||
`px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
|
||||
`flex items-center gap-1.5 px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border bg-surface px-4 py-2">
|
||||
<span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span>
|
||||
<nav className="flex gap-1">
|
||||
<NavLink to="/chat" className={link}>Chat</NavLink>
|
||||
<NavLink to="/review" className={link}>Review</NavLink>
|
||||
<NavLink to="/chat" className={link}>
|
||||
<MessageSquare size={16} /> Chat
|
||||
</NavLink>
|
||||
<NavLink to="/review" className={link}>
|
||||
<ShieldCheck size={16} /> Review
|
||||
{assets.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-accent px-1.5 text-xs text-bg">{assets.length}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
</nav>
|
||||
<button onClick={logout} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
|
||||
Déconnexion
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-muted" title="Phase · outils">
|
||||
<Cpu size={14} /> {phase || "…"} · {tools} outils
|
||||
</span>
|
||||
<button onClick={logout} className="ml-3 text-muted hover:text-fg cursor-pointer" aria-label="Déconnexion" title="Déconnexion">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<main className="flex-1 min-h-0">
|
||||
@@ -36,7 +49,13 @@ export function App() {
|
||||
const { token } = useAuth();
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{token ? <Shell /> : <Login />}
|
||||
{token ? (
|
||||
<AppDataProvider>
|
||||
<Shell />
|
||||
</AppDataProvider>
|
||||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api, ApiError, type Asset } from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
|
||||
/**
|
||||
* État applicatif partagé : phase/outils du backend + assets en attente de
|
||||
* review (pour le badge de nav et la vue Review). Rafraîchi à la connexion et
|
||||
* après chaque décision.
|
||||
*/
|
||||
interface AppData {
|
||||
phase: string;
|
||||
tools: number;
|
||||
assets: Asset[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
approve: (id: string) => Promise<void>;
|
||||
refuse: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AppData | null>(null);
|
||||
|
||||
export function AppDataProvider({ children }: { children: ReactNode }) {
|
||||
const { token, logout } = useAuth();
|
||||
const [phase, setPhase] = useState("");
|
||||
const [tools, setTools] = useState(0);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onErr = useCallback(
|
||||
(err: unknown): void => {
|
||||
if (err instanceof ApiError && err.status === 401) logout();
|
||||
else setError(err instanceof Error ? err.message : "Erreur");
|
||||
},
|
||||
[logout],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, r] = await Promise.all([api.state(token), api.review(token)]);
|
||||
setPhase(s.phase);
|
||||
setTools(s.tools);
|
||||
setAssets(r.assets);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
onErr(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, onErr]);
|
||||
|
||||
const decide = useCallback(
|
||||
async (id: string, action: "approve" | "refuse"): Promise<void> => {
|
||||
if (!token) return;
|
||||
try {
|
||||
if (action === "approve") await api.approve(token, id);
|
||||
else await api.refuse(token, id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
onErr(err);
|
||||
}
|
||||
},
|
||||
[token, refresh, onErr],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<Ctx.Provider
|
||||
value={{
|
||||
phase,
|
||||
tools,
|
||||
assets,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
approve: (id) => decide(id, "approve"),
|
||||
refuse: (id) => decide(id, "refuse"),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppData(): AppData {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useAppData hors AppDataProvider");
|
||||
return ctx;
|
||||
}
|
||||
+84
-17
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Mic, Square, Volume2, VolumeX, Radio, Send } from "lucide-react";
|
||||
import { useAuth } from "../auth";
|
||||
import { api, ApiError } from "../api";
|
||||
import { useSpeech } from "../useSpeech";
|
||||
|
||||
interface Msg {
|
||||
role: "user" | "assistant";
|
||||
@@ -9,27 +11,39 @@ interface Msg {
|
||||
|
||||
export function Chat() {
|
||||
const { token, logout } = useAuth();
|
||||
const speech = useSpeech();
|
||||
const [messages, setMessages] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [speakReplies, setSpeakReplies] = useState(() => localStorage.getItem("chlova.speak") === "1");
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, busy]);
|
||||
|
||||
const send = async (e: FormEvent): Promise<void> => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || busy || !token) return;
|
||||
const toggleSpeak = (): void => {
|
||||
setSpeakReplies((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem("chlova.speak", next ? "1" : "0");
|
||||
if (!next) speech.cancelSpeak();
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const sendText = useCallback(
|
||||
async (text: string): Promise<void> => {
|
||||
const t = text.trim();
|
||||
if (!t || busy || !token) return;
|
||||
setInput("");
|
||||
setError(null);
|
||||
setMessages((m) => [...m, { role: "user", text }]);
|
||||
setMessages((m) => [...m, { role: "user", text: t }]);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { reply } = await api.chat(token, text);
|
||||
const { reply } = await api.chat(token, t);
|
||||
setMessages((m) => [...m, { role: "assistant", text: reply }]);
|
||||
if (speakReplies || speech.handsFree) speech.speak(reply);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
logout();
|
||||
@@ -39,22 +53,38 @@ export function Chat() {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, token, speakReplies, speech, logout],
|
||||
);
|
||||
|
||||
const submit = (e: FormEvent): void => {
|
||||
e.preventDefault();
|
||||
void sendText(input);
|
||||
};
|
||||
|
||||
const mic = (): void => {
|
||||
if (speech.listening && !speech.handsFree) {
|
||||
speech.stopListening();
|
||||
return;
|
||||
}
|
||||
speech.listen((text) => void sendText(text)); // dicter → envoyer
|
||||
};
|
||||
|
||||
const toggleHandsFree = (): void => {
|
||||
if (speech.handsFree) speech.stopHandsFree();
|
||||
else speech.startHandsFree((text) => void sendText(text)); // « CHLOVA … » → envoyer
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<p className="text-muted text-sm">Pose une question à CHLOVA…</p>
|
||||
)}
|
||||
{messages.length === 0 && <p className="text-muted text-sm">Pose une question à CHLOVA…</p>}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
|
||||
<div
|
||||
className={
|
||||
"max-w-[80%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm " +
|
||||
(m.role === "user"
|
||||
? "bg-surface-2 border border-accent/40"
|
||||
: "bg-surface border border-border font-mono")
|
||||
(m.role === "user" ? "bg-surface-2 border border-accent/40" : "bg-surface border border-border font-mono")
|
||||
}
|
||||
>
|
||||
{m.text}
|
||||
@@ -62,24 +92,61 @@ export function Chat() {
|
||||
</div>
|
||||
))}
|
||||
{busy && <p className="text-muted text-sm animate-pulse">CHLOVA réfléchit…</p>}
|
||||
{speech.handsFree && !busy && !speech.speaking && (
|
||||
<p className="text-accent text-sm">Mains libres — dis « CHLOVA … »</p>
|
||||
)}
|
||||
{speech.speaking && <p className="text-accent text-sm">Lecture vocale…</p>}
|
||||
{error && <p role="alert" className="text-danger text-sm">{error}</p>}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={send} className="flex gap-2 border-t border-border bg-surface p-3">
|
||||
<form onSubmit={submit} className="flex items-center gap-2 border-t border-border bg-surface p-3">
|
||||
{speech.ttsSupported && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSpeak}
|
||||
aria-label={speakReplies ? "Couper la voix" : "Activer la voix"}
|
||||
title={speakReplies ? "Voix activée" : "Voix coupée"}
|
||||
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
|
||||
>
|
||||
{speakReplies ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="flex-1 rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent"
|
||||
placeholder="Message…"
|
||||
placeholder={speech.listening ? "Écoute…" : "Message…"}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
{speech.sttSupported && !speech.handsFree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={mic}
|
||||
aria-label={speech.listening ? "Arrêter le micro" : "Parler"}
|
||||
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.listening ? "border-accent text-accent animate-pulse" : "border-border text-muted"}`}
|
||||
>
|
||||
{speech.listening ? <Square size={18} /> : <Mic size={18} />}
|
||||
</button>
|
||||
)}
|
||||
{speech.sttSupported && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleHandsFree}
|
||||
aria-label={speech.handsFree ? "Couper les mains libres" : "Activer les mains libres"}
|
||||
title="Mains libres (wake-word CHLOVA)"
|
||||
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
|
||||
>
|
||||
<Radio size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !input.trim()}
|
||||
className="rounded-md bg-accent px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
|
||||
aria-label="Envoyer"
|
||||
className="flex items-center gap-1.5 rounded-md bg-accent px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
|
||||
>
|
||||
Envoyer
|
||||
<Send size={16} /> Envoyer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,62 @@
|
||||
// Vue Review — remplie en v0.24.0.
|
||||
import { Check, X, RefreshCw } from "lucide-react";
|
||||
import { useAppData } from "../appdata";
|
||||
import type { Asset } from "../api";
|
||||
|
||||
export function Review() {
|
||||
return <div className="p-6 text-muted">Review (v0.24.0)…</div>;
|
||||
const { assets, loading, error, refresh, approve, refuse } = useAppData();
|
||||
|
||||
const onRefuse = (id: string): void => {
|
||||
if (confirm(`Refuser définitivement ${id} ?`)) void refuse(id);
|
||||
};
|
||||
|
||||
const badge = (a: Asset): string => (a.riskTier === "privileged" ? "text-danger" : "text-success");
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Review</h2>
|
||||
<button
|
||||
onClick={() => void refresh()}
|
||||
className="ml-auto flex items-center gap-1.5 text-sm text-muted hover:text-fg cursor-pointer"
|
||||
>
|
||||
<RefreshCw size={15} /> Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-danger text-sm">{error}</p>}
|
||||
{loading && <p className="text-muted text-sm">Chargement…</p>}
|
||||
{!loading && assets.length === 0 && <p className="text-muted text-sm">Aucun asset en attente.</p>}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{assets.map((a) => (
|
||||
<li key={a.id} className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-sm">{a.id}</span>
|
||||
<span className={`text-xs ${badge(a)}`}>{a.riskTier}</span>
|
||||
<span className="text-xs text-muted">{a.status} · v{a.version}</span>
|
||||
{a.expiresAt && (
|
||||
<span className="text-xs text-warning">
|
||||
expire {new Date(a.expiresAt).toISOString().slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button
|
||||
onClick={() => void approve(a.id)}
|
||||
className="flex items-center gap-1 rounded-md bg-success/15 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
|
||||
>
|
||||
<Check size={15} /> Approuver
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRefuse(a.id)}
|
||||
className="flex items-center gap-1 rounded-md bg-danger/15 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
|
||||
>
|
||||
<X size={15} /> Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Voix (Phase 6) — 100 % navigateur (Web Speech API), aucun backend ni GPU.
|
||||
* - STT : SpeechRecognition (webkit) — Chrome/Edge. Dégrade proprement ailleurs.
|
||||
* - TTS : speechSynthesis — large support.
|
||||
* - Mains-libres : écoute en boucle, déclenchée par le wake-word « CHLOVA ».
|
||||
* Met le micro en pause pendant la synthèse vocale (évite l'auto-écoute).
|
||||
*/
|
||||
|
||||
interface SREvent {
|
||||
results: ArrayLike<ArrayLike<{ transcript: string }>>;
|
||||
}
|
||||
interface SR {
|
||||
lang: string;
|
||||
interimResults: boolean;
|
||||
continuous: boolean;
|
||||
maxAlternatives: number;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
onresult: ((e: SREvent) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onerror: ((e: unknown) => void) | null;
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition?: { new (): SR };
|
||||
webkitSpeechRecognition?: { new (): SR };
|
||||
}
|
||||
}
|
||||
|
||||
function makeRecognition(): SR | null {
|
||||
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
||||
if (!Ctor) return null;
|
||||
const r = new Ctor();
|
||||
r.lang = "fr-FR";
|
||||
r.interimResults = false;
|
||||
r.continuous = false;
|
||||
r.maxAlternatives = 1;
|
||||
return r;
|
||||
}
|
||||
|
||||
const WAKE = /\b(chlova|clova|klova)\b[\s,:.]*/i;
|
||||
|
||||
/** Renvoie la commande après le wake-word, ou null si absent. */
|
||||
export function extractCommand(transcript: string): string | null {
|
||||
const m = WAKE.exec(transcript);
|
||||
if (!m) return null;
|
||||
const cmd = transcript.slice(m.index + m[0].length).trim();
|
||||
return cmd.length > 0 ? cmd : null;
|
||||
}
|
||||
|
||||
export interface UseSpeech {
|
||||
sttSupported: boolean;
|
||||
ttsSupported: boolean;
|
||||
listening: boolean;
|
||||
speaking: boolean;
|
||||
handsFree: boolean;
|
||||
listen: (onText: (text: string) => void) => void;
|
||||
stopListening: () => void;
|
||||
speak: (text: string) => void;
|
||||
cancelSpeak: () => void;
|
||||
startHandsFree: (onCommand: (text: string) => void) => void;
|
||||
stopHandsFree: () => void;
|
||||
}
|
||||
|
||||
export function useSpeech(): UseSpeech {
|
||||
const recRef = useRef<SR | null>(null);
|
||||
const handsFreeRef = useRef(false);
|
||||
const onCommandRef = useRef<(t: string) => void>(() => {});
|
||||
const armRef = useRef<() => void>(() => {});
|
||||
const [listening, setListening] = useState(false);
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
const [handsFree, setHandsFree] = useState(false);
|
||||
|
||||
const sttSupported =
|
||||
typeof window !== "undefined" && !!(window.SpeechRecognition ?? window.webkitSpeechRecognition);
|
||||
const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window;
|
||||
|
||||
const stopListening = useCallback((): void => {
|
||||
recRef.current?.stop();
|
||||
setListening(false);
|
||||
}, []);
|
||||
|
||||
const listen = useCallback((onText: (text: string) => void): void => {
|
||||
const r = makeRecognition();
|
||||
if (!r) return;
|
||||
recRef.current = r;
|
||||
r.onresult = (e): void => {
|
||||
const text = e.results?.[0]?.[0]?.transcript ?? "";
|
||||
if (text) onText(text);
|
||||
};
|
||||
r.onend = (): void => setListening(false);
|
||||
r.onerror = (): void => setListening(false);
|
||||
setListening(true);
|
||||
r.start();
|
||||
}, []);
|
||||
|
||||
const cancelSpeak = useCallback((): void => {
|
||||
if (ttsSupported) window.speechSynthesis.cancel();
|
||||
setSpeaking(false);
|
||||
}, [ttsSupported]);
|
||||
|
||||
const speak = useCallback(
|
||||
(text: string): void => {
|
||||
if (!ttsSupported || !text.trim()) return;
|
||||
window.speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.lang = "fr-FR";
|
||||
const done = (): void => {
|
||||
setSpeaking(false);
|
||||
// En mains-libres, on réarme l'écoute APRÈS avoir parlé (anti auto-écoute).
|
||||
if (handsFreeRef.current) armRef.current();
|
||||
};
|
||||
u.onend = done;
|
||||
u.onerror = done;
|
||||
setSpeaking(true);
|
||||
window.speechSynthesis.speak(u);
|
||||
},
|
||||
[ttsSupported],
|
||||
);
|
||||
|
||||
// Boucle mains-libres : une écoute → détecte le wake-word → commande → réarme.
|
||||
armRef.current = (): void => {
|
||||
if (!handsFreeRef.current) return;
|
||||
if (ttsSupported && window.speechSynthesis.speaking) return; // attend la fin du TTS
|
||||
const r = makeRecognition();
|
||||
if (!r) return;
|
||||
recRef.current = r;
|
||||
r.onresult = (e): void => {
|
||||
const text = e.results?.[0]?.[0]?.transcript ?? "";
|
||||
const cmd = extractCommand(text);
|
||||
if (cmd) onCommandRef.current(cmd);
|
||||
};
|
||||
const rearm = (): void => {
|
||||
setListening(false);
|
||||
if (handsFreeRef.current && !(ttsSupported && window.speechSynthesis.speaking)) {
|
||||
setTimeout(() => armRef.current(), 400);
|
||||
}
|
||||
};
|
||||
r.onend = rearm;
|
||||
r.onerror = rearm;
|
||||
setListening(true);
|
||||
r.start();
|
||||
};
|
||||
|
||||
const startHandsFree = useCallback((onCommand: (text: string) => void): void => {
|
||||
onCommandRef.current = onCommand;
|
||||
handsFreeRef.current = true;
|
||||
setHandsFree(true);
|
||||
armRef.current();
|
||||
}, []);
|
||||
|
||||
const stopHandsFree = useCallback((): void => {
|
||||
handsFreeRef.current = false;
|
||||
setHandsFree(false);
|
||||
recRef.current?.abort();
|
||||
setListening(false);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
handsFreeRef.current = false;
|
||||
recRef.current?.abort();
|
||||
if (ttsSupported) window.speechSynthesis.cancel();
|
||||
},
|
||||
[ttsSupported],
|
||||
);
|
||||
|
||||
return {
|
||||
sttSupported,
|
||||
ttsSupported,
|
||||
listening,
|
||||
speaking,
|
||||
handsFree,
|
||||
listen,
|
||||
stopListening,
|
||||
speak,
|
||||
cancelSpeak,
|
||||
startHandsFree,
|
||||
stopHandsFree,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user