Compare commits

..

10 Commits

Author SHA1 Message Date
Kantin-Petit 4e23828dae feat(infra): déploie gitea + n8n-chlova sur local, topologie mono-hôte (v0.33.0)
Stacks gitea (1.26.4, git.pogoo.app) et n8n-chlova (2.20.8,
n8n-chlova.pogoo.app) déployés via Portainer sur l'hôte local (réseau proxy),
versionnés dans infra/. docker-compose.prod.yml recâblé pour le tout-local
(MCP_N8N_URL n8n-chlova, PORTAINER_URL interne, sidecar sur proxy). Runbook
deploy.md réécrit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
2026-06-23 11:40:24 +02:00
Kantin-Petit d824d16eed feat(infra): prêt au déploiement GitOps Portainer + Telegram optionnel (v0.32.0)
Compose de prod docker-compose.prod.yml (GitOps, sans env_file, réseau proxy
réel, certresolver letsencrypt) + runbook docs/deploy.md (Phase 1, users
chlova restreints Portainer/n8n). Surface Telegram rendue optionnelle pour un
déploiement UI-only ; garde assertHasSurface fail-closed. Typecheck + 78 tests
verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
2026-06-23 11:27:07 +02:00
Kantin-Petit faa1e82301 feat: app mobile React Native / Expo (v0.31.0)
Package mobile/ (Expo SDK 56, expo-router) réutilisant l'API backend :
Login (mdp+TOTP), Chat (+ TTS expo-speech), Review (approuver/refuser).
JWT en expo-secure-store, thème dark HUD, EXPO_PUBLIC_API_BASE. typecheck
vert. STT mobile reporté (lib native), TTS OK. Versions gérées par Expo.

Palier de risque : reversible (client mobile, API commune).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:39:38 +02:00
Kantin-Petit aa108e847b feat(ui): icônes Lucide + état/badge review partagés (v0.30.0)
Icônes SVG Lucide (chat, voix, review, nav). Contexte appdata : phase+outils
dans le header, badge d'assets en attente dans la nav, refresh après
décision ; Review consomme appdata. Build OK, 0 vuln.

Palier de risque : reversible (front).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:29:46 +02:00
Kantin-Petit 476c89ce3d feat: voix mains-libres + wake-word CHLOVA, fin Phase 6 v1 (v0.29.0)
Mode mains-libres : écoute en boucle déclenchée par le wake-word
« CHLOVA … » (extractCommand), micro en pause pendant le TTS pour éviter
l'auto-écoute ; réponses lues d'office. Bouton Libre + indicateur. 100%
navigateur. Build OK.

Palier de risque : reversible (front).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:24:41 +02:00
Kantin-Petit 76ad3b62fd feat: voix navigateur (STT push-to-talk + TTS) (v0.28.0)
Hook useSpeech (Web Speech API, fr-FR) : micro dicter→envoyer + lecture
vocale des réponses (bascule persistée). 100% navigateur, zéro backend/GPU,
dégrade si non supporté. Build OK.

Palier de risque : reversible (front).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:22:18 +02:00
Kantin-Petit 2bfa58f440 feat: outil propose_asset + auto-extension exposée, fin Phase 5 v1 (v0.27.0)
Outil local sanctionné chlova.propose_asset : l'agent propose un asset →
write+commit+version+doc → need-review (privilégié = BLOQUÉ). Notion
ToolSpec.sanctioned (autorisé par gatekeeper, audité). Flag
CHLOVA_AUTOEXT_ENABLED (off défaut) + CHLOVA_REPO_ROOT. Prompt impose un
palier honnête. 75 tests, 0 vuln, compose OK.

Palier de risque : privilégié (l'agent écrit+commit) — derrière flag +
Phase 2 ; l'asset produit n'est jamais exécuté, il reste sous review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:38:45 +02:00
Kantin-Petit bc61434f7c feat: GitCommitter + AutoExtensionService (v0.26.0)
propose() : écrit artefact+doc → commit ciblé + version → enregistre
l'asset en need-review (privilégié = BLOQUÉ sans sursis) → alerte
asset_created (version+commit+doc). N'exécute jamais l'asset. Commits
ciblés (jamais add -A). 73 tests (dépôt git temp), 0 vuln.

Palier de risque : privilégié (écrit + commit dans le dépôt) — derrière
flag + Phase 2 ; n'exécute aucun asset, tout reste sous review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:35:08 +02:00
Kantin-Petit d1255b926b feat: ArtifactWriter auto-extension (artefact + doc) (v0.25.0)
Écrit un asset auto-créé (workflow/outil) + sa doc générée depuis le
gabarit, chemins sanitizés (anti-traversée), semver validé. 5 tests
(dépôt temp). Le dépôt fait foi avant tout passage en need-review.

Palier de risque : reversible (écriture fichier locale, sans commit ni exécution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:32:49 +02:00
Kantin-Petit e6edf1a8bc feat: UI vue Review + backend sert le SPA, fin Phase 4 v1 (v0.24.0)
Vue Review (liste assets, approuver/refuser + confirm, refresh, 401→logout).
Backend sert le SPA same-origin (@fastify/static + fallback) si CHLOVA_WEB_ROOT.
Dockerfile multi-stage build web+API (contexte racine), image embarque /app/web.
Compose contexte .., image chlova/backend:0.2.0. 65 tests, 0 vuln, compose OK.

Palier de risque : privilégié (surface exposée complète) — non déployée ;
auth + CHLOVA_PHASE requis pour activer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:56:01 +02:00
50 changed files with 10129 additions and 77 deletions
+13 -2
View File
@@ -27,8 +27,10 @@ PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RES
PORTAINER_READ_ONLY=true PORTAINER_READ_ONLY=true
MCP_PORTAINER_URL=http://mcp-portainer:3000 MCP_PORTAINER_URL=http://mcp-portainer:3000
# ── Surface Telegram (Phase 1) ───────────────────────────────────────── # ── Surface Telegram (OPTIONNELLE) ─────────────────────────────────────
TELEGRAM_BOT_TOKEN= # SECRET — token du bot # 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 TELEGRAM_ALLOWED_USER_IDS= # liste d'IDs autorisés, séparés par virgule
# ── Backend CHLOVA ───────────────────────────────────────────────────── # ── Backend CHLOVA ─────────────────────────────────────────────────────
@@ -52,6 +54,15 @@ CHLOVA_TOTP_SECRET= # SECRET — secret TOTP (2FA)
CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT
# Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin. # Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin.
CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement) 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). # Domaine public derrière Traefik (label compose).
CHLOVA_DOMAIN=chlova.example.com CHLOVA_DOMAIN=chlova.example.com
# Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI. # Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI.
+104
View File
@@ -6,6 +6,110 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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 ## [0.23.0] — 2026-06-23
### Added ### Added
- UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via - UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via
+3 -1
View File
@@ -17,7 +17,9 @@ Ollama **cloud**.
|---|---| |---|---|
| `docs/` | Architecture, sécurité, versioning, paliers de risque, gabarit d'asset | | `docs/` | Architecture, sécurité, versioning, paliers de risque, gabarit d'asset |
| `infra/` | docker-compose de la stack + socket-proxy + notes réseau | | `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) | | `workflows-n8n/` | Exports JSON des workflows (le dépôt fait foi) |
## Démarrage (dev) ## Démarrage (dev)
+133
View File
@@ -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
View File
@@ -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`). `workflows-n8n/chlova-alerts.v1.0.0.json` (doc : `docs/assets/workflow-chlova-alerts.md`).
Le payload ne contient aucun secret. Le payload ne contient aucun secret.
## Reste à faire (Phase 4+) ## Auto-extension (Phase 5 — implémenté)
- Auto-extension : CHLOVA génère commit + version + doc d'un asset **avant** Quand aucune capacité n'existe, l'agent appelle l'outil **sanctionné**
de le passer en need-review (Phase 4). `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).
+140
View File
@@ -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:
+8 -2
View File
@@ -78,19 +78,25 @@ services:
# ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ────────────── # ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ──────────────
backend: backend:
build: build:
context: ../orchestrator # Dockerfile ajouté en Phase 1 context: .. # racine du dépôt (image = API + SPA web)
image: chlova/backend:0.1.0 # tag versionné local dockerfile: orchestrator/Dockerfile
image: chlova/backend:0.2.0 # tag versionné local (API+UI)
restart: unless-stopped restart: unless-stopped
env_file: ../.env env_file: ../.env
environment: environment:
CHLOVA_ENV: ${CHLOVA_ENV:-production} CHLOVA_ENV: ${CHLOVA_ENV:-production}
CHLOVA_PHASE: ${CHLOVA_PHASE:-1} # 1 = lecture seule (défaut) ; 2 = écriture sous review 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 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} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
MCP_N8N_URL: ${MCP_N8N_URL} # endpoint MCP natif de n8n MCP_N8N_URL: ${MCP_N8N_URL} # endpoint MCP natif de n8n
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000} MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000}
volumes: volumes:
- chlova-data:/app/data # SQLite (table assets, P2+) - 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: depends_on:
- ollama - ollama
- mcp-portainer - mcp-portainer
+43
View File
@@ -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:
+67
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
.expo/
dist/
web-build/
*.log
# Builds natifs générés (prebuild)
/ios
/android
.env*.local
+31
View File
@@ -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.
+22
View File
@@ -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": {}
}
}
}
+36
View File
@@ -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>
);
}
+97
View File
@@ -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 },
});
+108
View File
@@ -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 },
});
+18
View File
@@ -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>
);
}
+16
View File
@@ -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"} />;
}
+54
View File
@@ -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" },
});
+4
View File
@@ -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`.
+7735
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+56
View File
@@ -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),
};
+48
View File
@@ -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;
}
+13
View File
@@ -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",
};
+11
View File
@@ -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
View File
@@ -1,21 +1,35 @@
# CHLOVA backend — image multi-stage, base épinglée (jamais :latest). # 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. # TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel.
FROM node:24.13-bookworm-slim AS build # ── Build du SPA (web/) ─────────────────────────────────────────────────
WORKDIR /app FROM node:24.13-bookworm-slim AS web-build
COPY package.json package-lock.json* ./ WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci RUN npm ci
COPY tsconfig.json tsconfig.build.json ./ COPY web/ ./
COPY src ./src
RUN npm run build 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 FROM node:24.13-bookworm-slim AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV CHLOVA_WEB_ROOT=/app/web
WORKDIR /app 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 RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist COPY --from=api-build /app/dist ./dist
# Données runtime (SQLite, P2+). L'utilisateur node ne tourne pas en root. 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 RUN mkdir -p /app/data && chown -R node:node /app
USER node USER node
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
+165 -2
View File
@@ -8,8 +8,9 @@
"name": "chlova-orchestrator", "name": "chlova-orchestrator",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "11.2.0",
"@fastify/rate-limit": "^11.0.0", "@fastify/rate-limit": "11.0.0",
"@fastify/static": "^9.1.3",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"jose": "6.2.3", "jose": "6.2.3",
@@ -503,6 +504,22 @@
"node": ">=18" "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": { "node_modules/@fastify/ajv-compiler": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -655,6 +672,53 @@
"toad-cache": "^3.7.0" "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": { "node_modules/@hono/node-server": {
"version": "1.19.14", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -1392,6 +1456,15 @@
"fastq": "^1.17.1" "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": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1429,6 +1502,18 @@
"url": "https://opencollective.com/express" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2076,6 +2161,23 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2556,6 +2658,15 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2596,6 +2707,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2621,6 +2744,30 @@
"url": "https://opencollective.com/express" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2752,6 +2899,22 @@
"node": ">=8" "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": { "node_modules/path-to-regexp": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+1
View File
@@ -19,6 +19,7 @@
"dependencies": { "dependencies": {
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/rate-limit": "11.0.0", "@fastify/rate-limit": "11.0.0",
"@fastify/static": "9.1.3",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5", "fastify": "5.8.5",
"jose": "6.2.3", "jose": "6.2.3",
+7 -1
View File
@@ -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 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 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 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 { export function buildSystemPrompt(phase: 1 | 2): string {
return `${COMMON}\n\n${phase === 2 ? PHASE_2 : PHASE_1}`; return `${COMMON}\n\n${phase === 2 ? PHASE_2 : PHASE_1}`;
+6
View File
@@ -17,6 +17,12 @@ export interface ToolSpec {
readOnly: boolean; readOnly: boolean;
/** Palier de risque résolu (voir docs/risk-tiers.md). */ /** Palier de risque résolu (voir docs/risk-tiers.md). */
riskTier: RiskTier; 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 { export interface ToolHandle {
+10
View File
@@ -37,6 +37,16 @@ export type AlertEvent =
blocked: number; blocked: number;
provisional: number; provisional: number;
items: DigestItem[]; items: DigestItem[];
}
| {
kind: "asset_created";
assetId: string;
assetType: string;
version: string;
riskTier: string;
status: string;
commit: string;
doc: string;
}; };
export interface AlertSender { export interface AlertSender {
+104
View File
@@ -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 };
}
}
+47
View File
@@ -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"];
}
+67
View File
@@ -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.")
);
},
};
}
+32 -2
View File
@@ -45,8 +45,10 @@ const schema = z.object({
.default("true") .default("true")
.transform((v) => v.toLowerCase() !== "false"), .transform((v) => v.toLowerCase() !== "false"),
// Surface Telegram // Surface Telegram (OPTIONNELLE) : si le token est absent, la surface n'est
telegramBotToken: nonEmpty, // SECRET // 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 telegramAllowedUserIds: z
.string() .string()
.default("") .default("")
@@ -78,6 +80,16 @@ const schema = z.object({
(v) => (typeof v === "string" && v.length > 0 ? v : undefined), (v) => (typeof v === "string" && v.length > 0 ? v : undefined),
z.string().url().optional(), z.string().url().optional(),
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin ), // 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>; 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, totpSecret: env.CHLOVA_TOTP_SECRET,
jwtSecret: env.CHLOVA_JWT_SECRET, jwtSecret: env.CHLOVA_JWT_SECRET,
webOrigin: env.CHLOVA_WEB_ORIGIN, webOrigin: env.CHLOVA_WEB_ORIGIN,
webRoot: env.CHLOVA_WEB_ROOT,
autoextEnabled: env.CHLOVA_AUTOEXT_ENABLED,
repoRoot: env.CHLOVA_REPO_ROOT,
}); });
if (!parsed.success) { 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 * 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). * (API/UI désactivée — surface non exposée).
@@ -48,6 +48,11 @@ export class Gatekeeper {
* incrément du compteur d'exécution). * incrément du compteur d'exécution).
*/ */
authorizeTool(spec: ToolSpec): { allowed: boolean; reason?: string } { 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. // Lecture seule : pas d'asset, pas de review.
if (spec.riskTier === "reversible" && spec.readOnly) { if (spec.riskTier === "reversible" && spec.readOnly) {
return { allowed: true }; return { allowed: true };
+46 -5
View File
@@ -1,5 +1,8 @@
import Fastify, { type FastifyBaseLogger } from "fastify"; 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 { registerApi } from "./api/routes.js";
import { createLogger } from "./audit/log.js"; import { createLogger } from "./audit/log.js";
import { OllamaClient } from "./llm/ollama.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 { HttpAlertSender, NullAlertSender } from "./alerts/sender.js";
import { startAlertScheduler } from "./alerts/scheduler.js"; import { startAlertScheduler } from "./alerts/scheduler.js";
import type { AlertSender } from "./alerts/types.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 type { Guard, ToolHandle } from "./agent/types.js";
import { ChatService } from "./agent/chat-service.js"; import { ChatService } from "./agent/chat-service.js";
import { buildSystemPrompt } from "./agent/system-prompt.js"; import { buildSystemPrompt } from "./agent/system-prompt.js";
@@ -28,6 +34,7 @@ import { TelegramSurface } from "./surfaces/telegram.js";
async function main(): Promise<void> { async function main(): Promise<void> {
const cfg = loadConfig(); const cfg = loadConfig();
assertReadOnlyPhase(cfg); assertReadOnlyPhase(cfg);
assertHasSurface(cfg);
const logger = createLogger(cfg.logLevel); const logger = createLogger(cfg.logLevel);
logger.info({ config: redactedConfig(cfg) }, "CHLOVA backend démarrage"); logger.info({ config: redactedConfig(cfg) }, "CHLOVA backend démarrage");
@@ -77,6 +84,15 @@ async function main(): Promise<void> {
}); });
guard = new GatekeeperGuard(gatekeeper); guard = new GatekeeperGuard(gatekeeper);
tools = await registry.listAllTools(); 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); review = new ReviewService(repo, logger);
stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire
stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1 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 }); const chat = new ChatService({ client, tools, guard, systemPrompt, logger });
// ── Surface Telegram (long-polling) ───────────────────────────────────── // ── Surface Telegram (long-polling) — optionnelle ───────────────────────
const telegram = new TelegramSurface( // 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, botToken: cfg.telegramBotToken,
allowedUserIds: cfg.telegramAllowedUserIds, allowedUserIds: cfg.telegramAllowedUserIds,
}, },
logger, logger,
); )
: null;
// ── Healthcheck interne (jamais publié) ───────────────────────────────── // ── Healthcheck interne (jamais publié) ─────────────────────────────────
// pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le // 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"); 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 }); await app.listen({ host: "0.0.0.0", port: 8080 });
logger.info({ port: 8080 }, "healthcheck interne prêt"); logger.info({ port: 8080 }, "healthcheck interne prêt");
// Arrêt propre. // Arrêt propre.
const shutdown = async (): Promise<void> => { const shutdown = async (): Promise<void> => {
telegram.stop(); telegram?.stop();
stopCron?.(); stopCron?.();
stopAlerts?.(); stopAlerts?.();
await registry.close(); await registry.close();
@@ -145,12 +183,15 @@ async function main(): Promise<void> {
process.on("SIGINT", () => void shutdown()); process.on("SIGINT", () => void shutdown());
// Boucle de service : commande de review (Phase 2) ou tour d'agent. // 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 }) => { await telegram.start(async ({ userId, text }) => {
if (review && isCommand(text)) { if (review && isCommand(text)) {
return handleReviewCommand(review, text); return handleReviewCommand(review, text);
} }
return chat.handle(`telegram:${userId}`, text); return chat.handle(`telegram:${userId}`, text);
}); });
}
} }
main().catch((err: unknown) => { main().catch((err: unknown) => {
+60
View File
@@ -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/);
});
});
+83
View File
@@ -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/);
});
});
+66
View File
@@ -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);
});
});
+28 -1
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest"; 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 => ({ const fullEnv = (): NodeJS.ProcessEnv => ({
OLLAMA_BASE_URL: "http://ollama:11434", 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", () => { describe("redactedConfig masque les secrets", () => {
it("ne révèle aucun secret", () => { it("ne révèle aucun secret", () => {
const red = redactedConfig(loadConfig(fullEnv())); const red = redactedConfig(loadConfig(fullEnv()));
+11 -2
View File
@@ -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/Chat.tsx` | Conversation agent (v0.23.0). |
| `src/pages/Review.tsx` | Need-review : approuver/refuser (v0.24.0). | | `src/pages/Review.tsx` | Need-review : approuver/refuser (v0.24.0). |
## Périmètre v1 ## Voix (Phase 6)
Login → Chat → Review. Voix + app RN : phases ultérieures (API commune réutilisée). 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).
+10
View File
@@ -8,6 +8,7 @@
"name": "chlova-web", "name": "chlova-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"lucide-react": "^1.21.0",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-router-dom": "7.18.0" "react-router-dom": "7.18.0"
@@ -4939,6 +4940,15 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit"
}, },
"dependencies": { "dependencies": {
"lucide-react": "1.21.0",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-router-dom": "7.18.0" "react-router-dom": "7.18.0"
+25 -6
View File
@@ -1,24 +1,37 @@
import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom";
import { MessageSquare, ShieldCheck, LogOut, Cpu } from "lucide-react";
import { useAuth } from "./auth"; import { useAuth } from "./auth";
import { AppDataProvider, useAppData } from "./appdata";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { Chat } from "./pages/Chat"; import { Chat } from "./pages/Chat";
import { Review } from "./pages/Review"; import { Review } from "./pages/Review";
function Shell() { function Shell() {
const { logout } = useAuth(); const { logout } = useAuth();
const { phase, tools, assets } = useAppData();
const link = ({ isActive }: { isActive: boolean }): string => 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 ( return (
<div className="min-h-dvh flex flex-col"> <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"> <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> <span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span>
<nav className="flex gap-1"> <nav className="flex gap-1">
<NavLink to="/chat" className={link}>Chat</NavLink> <NavLink to="/chat" className={link}>
<NavLink to="/review" className={link}>Review</NavLink> <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> </nav>
<button onClick={logout} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer"> <span className="ml-auto flex items-center gap-1.5 text-xs text-muted" title="Phase · outils">
Déconnexion <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> </button>
</header> </header>
<main className="flex-1 min-h-0"> <main className="flex-1 min-h-0">
@@ -36,7 +49,13 @@ export function App() {
const { token } = useAuth(); const { token } = useAuth();
return ( return (
<BrowserRouter> <BrowserRouter>
{token ? <Shell /> : <Login />} {token ? (
<AppDataProvider>
<Shell />
</AppDataProvider>
) : (
<Login />
)}
</BrowserRouter> </BrowserRouter>
); );
} }
+95
View File
@@ -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
View File
@@ -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 { useAuth } from "../auth";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import { useSpeech } from "../useSpeech";
interface Msg { interface Msg {
role: "user" | "assistant"; role: "user" | "assistant";
@@ -9,27 +11,39 @@ interface Msg {
export function Chat() { export function Chat() {
const { token, logout } = useAuth(); const { token, logout } = useAuth();
const speech = useSpeech();
const [messages, setMessages] = useState<Msg[]>([]); const [messages, setMessages] = useState<Msg[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [speakReplies, setSpeakReplies] = useState(() => localStorage.getItem("chlova.speak") === "1");
const bottom = useRef<HTMLDivElement>(null); const bottom = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
bottom.current?.scrollIntoView({ behavior: "smooth" }); bottom.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, busy]); }, [messages, busy]);
const send = async (e: FormEvent): Promise<void> => { const toggleSpeak = (): void => {
e.preventDefault(); setSpeakReplies((v) => {
const text = input.trim(); const next = !v;
if (!text || busy || !token) return; 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(""); setInput("");
setError(null); setError(null);
setMessages((m) => [...m, { role: "user", text }]); setMessages((m) => [...m, { role: "user", text: t }]);
setBusy(true); setBusy(true);
try { try {
const { reply } = await api.chat(token, text); const { reply } = await api.chat(token, t);
setMessages((m) => [...m, { role: "assistant", text: reply }]); setMessages((m) => [...m, { role: "assistant", text: reply }]);
if (speakReplies || speech.handsFree) speech.speak(reply);
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) { if (err instanceof ApiError && err.status === 401) {
logout(); logout();
@@ -39,22 +53,38 @@ export function Chat() {
} finally { } finally {
setBusy(false); 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 ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3"> <div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{messages.length === 0 && ( {messages.length === 0 && <p className="text-muted text-sm">Pose une question à CHLOVA</p>}
<p className="text-muted text-sm">Pose une question à CHLOVA</p>
)}
{messages.map((m, i) => ( {messages.map((m, i) => (
<div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}> <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
<div <div
className={ className={
"max-w-[80%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm " + "max-w-[80%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm " +
(m.role === "user" (m.role === "user" ? "bg-surface-2 border border-accent/40" : "bg-surface border border-border font-mono")
? "bg-surface-2 border border-accent/40"
: "bg-surface border border-border font-mono")
} }
> >
{m.text} {m.text}
@@ -62,24 +92,61 @@ export function Chat() {
</div> </div>
))} ))}
{busy && <p className="text-muted text-sm animate-pulse">CHLOVA réfléchit</p>} {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>} {error && <p role="alert" className="text-danger text-sm">{error}</p>}
<div ref={bottom} /> <div ref={bottom} />
</div> </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 <input
className="flex-1 rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent" 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} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
disabled={busy} 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 <button
type="submit" type="submit"
disabled={busy || !input.trim()} 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> </button>
</form> </form>
</div> </div>
+60 -2
View File
@@ -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() { 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>
);
} }
+184
View File
@@ -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,
};
}