Compare commits

...

14 Commits

Author SHA1 Message Date
Kantin-Petit 4f3c85901e feat(web): UI responsive mobile (header, barre chat, review) (v0.35.0)
Header flex-wrap + détails masqués sous sm ; barre chat input min-w-0 +
boutons compacts + label Envoyer masqué sous sm ; actions Review empilées
pleine largeur sur mobile. Build web vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
2026-06-23 16:06:56 +02:00
Kantin-Petit ef382274fe fix(ollama): bump 0.6.8 -> 0.30.10 pour modèles :cloud (v0.34.2)
0.6.8 renvoie 412 sur les tags :cloud. Bump image (prod+dev) + .env.example
oriente vers un tag cloud valide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
2026-06-23 15:51:05 +02:00
Kantin-Petit 17aec9a721 docs(deploy): stack chlova déployé + note secret passerelle ≥32 car (v0.34.1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
2026-06-23 13:55:24 +02:00
Kantin-Petit 2d3c944699 fix(mcp): intégration réelle passerelle MCP Portainer (image+auth) (v0.34.0)
Image portainer-mcp 0.6.0 (inexistant) -> 2.42.6. Passerelle HTTP :17717/mcp
attend Bearer (secret passerelle) + X-Portainer-API-Key (clé API restreinte
chlova) : ajout config.portainerApiKey + McpServerConfig.extraHeaders, backend
envoie les deux. socket-proxy supprimé (plus de socket monté). Compose prod,
.env.example, deploy.md à jour. 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 13:50:09 +02:00
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
51 changed files with 10204 additions and 91 deletions
+26 -7
View File
@@ -7,7 +7,10 @@
# ollama.com. Joignable uniquement sur le réseau Docker interne.
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_API_KEY= # SECRET — clé API Ollama cloud
OLLAMA_MODEL=qwen3:cloud # modèle cloud (suffixe :cloud), tool-calling
# Modèle cloud (suffixe :cloud), doit supporter le tool-calling. Choisir un tag
# VALIDE listé sur ollama.com (ex. qwen3-coder:480b-cloud, gpt-oss:120b-cloud).
# Requiert Ollama >= 0.30 (image bumpée). Vérifier : `ollama pull <tag>`.
OLLAMA_MODEL=qwen3-coder:480b-cloud
# ── MCP n8n : NATIF (instance n8n ≥ 2.18.4) ────────────────────────────
# Pas de conteneur dédié : n8n sert son propre MCP. Activer côté instance
@@ -19,16 +22,23 @@ OLLAMA_MODEL=qwen3:cloud # modèle cloud (suffixe :cloud), tool-call
MCP_N8N_URL=http://n8n:5678/mcp-server/http # ou https://<n8n-public>/mcp-server/http
MCP_N8N_AUTH_TOKEN= # SECRET — "MCP Access Token" n8n (Bearer)
# ── MCP Portainer (portainer/portainer-mcp) ────────────────────────────
PORTAINER_URL=https://portainer:9443 # interne uniquement
PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RESTREINTE
# ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) ────────────
PORTAINER_URL=http://portainer:9000 # API Portainer, interne (même hôte)
# Secret de PASSERELLE (front-gate) : même valeur côté backend et côté serveur
# MCP. Envoyé en Authorization: Bearer. N'autorise que l'accès à la passerelle.
PORTAINER_MCP_AUTH_TOKEN= # SECRET — secret de passerelle (au choix)
# Clé API Portainer de l'utilisateur `chlova` (RESTREINTE) : envoyée en header
# X-Portainer-API-Key. C'est elle qui cloisonne l'accès réel de CHLOVA.
PORTAINER_API_KEY= # SECRET — Access token Portainer de chlova
# Phase 1 : DOIT rester true (le boot échoue sinon). Phase 2 : peut passer false
# pour autoriser les écritures Portainer (sous gatekeeper + review).
PORTAINER_READ_ONLY=true
MCP_PORTAINER_URL=http://mcp-portainer:3000
MCP_PORTAINER_URL=http://mcp-portainer:17717/mcp
# ── Surface Telegram (Phase 1) ─────────────────────────────────────────
TELEGRAM_BOT_TOKEN= # SECRET — token du bot
# ── Surface Telegram (OPTIONNELLE) ─────────────────────────────────────
# Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une
# AUTRE surface (API/UI ci-dessous), sinon il refuse de démarrer (fail-closed).
TELEGRAM_BOT_TOKEN= # SECRET — token du bot (vide = pas de Telegram)
TELEGRAM_ALLOWED_USER_IDS= # liste d'IDs autorisés, séparés par virgule
# ── Backend CHLOVA ─────────────────────────────────────────────────────
@@ -52,6 +62,15 @@ CHLOVA_TOTP_SECRET= # SECRET — secret TOTP (2FA)
CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT
# Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin.
CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement)
# Racine du SPA buildé servi same-origin. Défaut image = /app/web. Vide = pas de SPA.
CHLOVA_WEB_ROOT= # laisser vide en conteneur (défaut /app/web)
# ── Auto-extension (Phase 5) ───────────────────────────────────────────
# Si true, l'agent peut créer des assets en need-review (écrit + commit +
# versionne + documente). Désactivé par défaut (fail-safe). Requiert un dépôt
# git monté dans le conteneur à CHLOVA_REPO_ROOT.
CHLOVA_AUTOEXT_ENABLED=false
CHLOVA_REPO_ROOT=. # chemin du dépôt (working copy GitOps)
# Domaine public derrière Traefik (label compose).
CHLOVA_DOMAIN=chlova.example.com
# Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI.
+152
View File
@@ -6,6 +6,158 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased]
## [0.35.0] — 2026-06-23 — UI responsive (mobile)
### Fixed
- **Header** : `flex-wrap` + paddings/marges réduits sur petit écran, « · N outils »
masqué sous `sm` (ne déborde plus).
- **Barre chat** : `input` en `min-w-0`, boutons `shrink-0` compactés
(`px-2.5 sm:px-3`), label « Envoyer » masqué sous `sm` → tient sur mobile.
- **Review** : actions Approuver/Refuser pleine largeur empilées sous `sm`.
- Web : typecheck + build OK.
## [0.34.2] — 2026-06-23 — bump Ollama 0.30.10 (modèles :cloud)
### Fixed
- `ollama/ollama:0.6.8`**`0.30.10`** : 0.6.8 refuse les modèles `:cloud`
(HTTP 412 « requires a newer version of Ollama »). Bump dans
`docker-compose.prod.yml` et `docker-compose.yml`.
### Changed
- `.env.example` : `OLLAMA_MODEL` → choisir un tag `:cloud` valide (ex.
`qwen3-coder:480b-cloud`), note sur Ollama >= 0.30.
## [0.34.1] — 2026-06-23 — stack chlova déployé (placeholders), notes runbook
### Added
- Stack `chlova` **déployé** en GitOps sur `local` (id 10) : `ollama` running,
`backend` + `mcp-portainer` en attente des vraies valeurs (placeholders).
- `docs/deploy.md` : note « stack pré-créé » (éditer les 7 vars → Update) et
contrainte `PORTAINER_MCP_AUTH_TOKEN` **≥ 32 caractères** (sinon le serveur
MCP Portainer refuse de démarrer — constaté au déploiement).
## [0.34.0] — 2026-06-23 — intégration réelle passerelle MCP Portainer
### Fixed
- **Image MCP Portainer** : `portainer/portainer-mcp:0.6.0` (tag inexistant)
**`2.42.6`** (confirmé Docker Hub). Découvert au déploiement réel (pull 404).
- **Modèle d'auth de la passerelle MCP Portainer** corrigé : le serveur HTTP
(port **17717**, endpoint **`/mcp`**) attend `Authorization: Bearer <secret de
passerelle>` **et** `X-Portainer-API-Key: <clé API restreinte>`. Le backend
n'envoyait que le Bearer.
### Added
- `config.portainerApiKey` (`PORTAINER_API_KEY`, SECRET) : clé API Portainer
restreinte de `chlova`, envoyée en `X-Portainer-API-Key` — c'est elle qui
cloisonne l'accès. `PORTAINER_MCP_AUTH_TOKEN` devient le secret de passerelle.
- `McpServerConfig.extraHeaders` : headers additionnels par serveur MCP.
### Changed
- `docker-compose.prod.yml` : `mcp-portainer` reconfiguré (2.42.6, :17717,
`PORTAINER_MCP_ALLOWED_HOSTS`, plaintext HTTP interne, `PORTAINER_TLS_VERIFY=0`,
`PORTAINER_READ_ONLY`). **`socket-proxy` supprimé** (la passerelle parle à
l'API Portainer, plus aucun socket Docker monté = surface réduite).
`MCP_PORTAINER_URL``:17717/mcp`. Backend reçoit `PORTAINER_API_KEY`.
- `.env.example` + `docs/deploy.md` (§3a, tableau variables) mis à jour.
- Typecheck vert, 78 tests verts.
## [0.33.0] — 2026-06-23 — infra locale déployée (gitea + n8n-chlova)
### Added
- **`infra/gitea/docker-compose.yml`** : serveur git Gitea 1.26.4 sur l'hôte
`local` (réseau `proxy`, `git.pogoo.app`), source du GitOps de CHLOVA.
**Déployé** via Portainer (stack id 6, running).
- **`infra/n8n-chlova/docker-compose.yml`** : n8n dédié à CHLOVA (2.20.8) sur
`local` (`n8n-chlova.pogoo.app`), MCP natif interne `http://n8n-chlova:5678`.
**Déployé** via Portainer (stack id 7, running healthy).
### Changed
- Topologie cible repliée sur **un seul hôte (`local`)** : tout sur le réseau
`proxy`, joignable en interne par nom de conteneur.
- `docker-compose.prod.yml` : `MCP_N8N_URL``n8n-chlova`, `PORTAINER_URL`
interne `http://portainer:9000` (même hôte), sidecar `mcp-portainer` ajouté au
réseau `proxy` (pour joindre le serveur Portainer).
- `docs/deploy.md` réécrit pour la topologie locale (DNS, gitea, push, users
restreints, déploiement GitOps, vérif).
## [0.32.0] — 2026-06-23 — prêt au déploiement (GitOps Portainer, Phase 1)
### Added
- **`infra/docker-compose.prod.yml`** : compose de PRODUCTION pour GitOps
Portainer. Sans `env_file` (secrets via variables de stack), réseau Traefik
réel `proxy` (external), certresolver `letsencrypt`, backend joint `proxy`
pour atteindre n8n en interne. Cible : env `vps-pogoo-002`.
- **`docs/deploy.md`** : runbook Phase 1 — dépôt git, génération secrets,
users restreints `chlova` (Portainer + n8n, étapes UI), variables de stack,
déploiement, vérification, rollback, bascule Phase 2.
- `config.assertHasSurface()` : refuse de démarrer si aucune surface (Telegram
OU API/UI) n'est configurée. 3 tests (11 au total dans config).
### Changed
- **Surface Telegram rendue optionnelle** (`telegramBotToken` optionnel ;
`index.ts` ne démarre la surface que si le token est présent). Permet le
déploiement **UI-only** voulu. `.env.example` documenté en conséquence.
- Typecheck vert, 78 tests verts.
## [0.31.0] — 2026-06-23 — app mobile (React Native / Expo)
### Added
- Package **`mobile/`** : app Expo SDK 56 (expo-router) réutilisant l'API backend.
Écrans Login (mdp + TOTP), Chat (+ TTS expo-speech), Review (approuver/refuser).
JWT en `expo-secure-store`, thème dark HUD, `EXPO_PUBLIC_API_BASE` configurable.
`tsc --noEmit` vert. STT mobile reporté (lib native) ; TTS OK.
- README racine : entrées `web/` et `mobile/`.
## [0.30.0] — 2026-06-23 — polish UI
### Added
- Icônes **Lucide** (SVG) partout (chat, voix, review, nav) — fini le texte/emoji.
- Contexte `appdata` : état backend (phase/outils) + assets en attente partagés ;
header affiche phase·outils, **badge de review** dans la nav, refresh après décision.
### Changed
- `Review` consomme `appdata` (plus de fetch local). Build OK, 0 vuln.
## [0.29.0] — 2026-06-23 — fin Phase 6 (voix v1)
### Added
- `useSpeech` : mode **mains-libres** + wake-word « CHLOVA » (`extractCommand`),
écoute en boucle, micro en pause pendant le TTS (anti auto-écoute).
- Chat : bouton "Libre" (mains-libres) ; en mains-libres les réponses sont lues
d'office. Indicateur d'écoute. Build OK. (README web : section voix.)
## [0.28.0] — 2026-06-23 — début Phase 6 (voix)
### Added
- `web/src/useSpeech.ts` : hook voix 100 % navigateur (Web Speech API), STT
(push-to-talk, fr-FR) + TTS, dégrade proprement si non supporté. Zéro backend/GPU.
- Chat : bouton micro (dicter → envoyer), bascule "Voix ON/OFF" (persistée) qui lit
les réponses à voix haute. Pas d'emoji comme icône (texte). Build OK.
## [0.27.0] — 2026-06-23 — fin Phase 5 (auto-extension v1)
### Added
- Outil local **`chlova.propose_asset`** (`src/autoext/tool.ts`) exposé à l'agent :
propose un asset → write+commit+doc → need-review. Notion d'outil **sanctionné**
(`ToolSpec.sanctioned`) autorisé par le gatekeeper mais audité.
- Config `CHLOVA_AUTOEXT_ENABLED` (défaut false) + `CHLOVA_REPO_ROOT`. Câblage
Phase 2 + flag. Prompt système Phase 2 mis à jour (palier honnête imposé).
- Tests (2) : gatekeeper autorise un outil sanctionné ; tool propose. 75 tests.
### Changed
- `.env.example` + compose : flag autoext + montage dépôt (commenté).
`docs/need-review.md` : section auto-extension. Compose revalidé, 0 vuln.
## [0.26.0] — 2026-06-23
### Added
- `src/autoext/git-committer.ts` : `GitCommitter` — commits ciblés (jamais
`git add -A`), auteur CHLOVA, renvoie le SHA.
- `src/autoext/auto-extension.ts` : `AutoExtensionService.propose` — write+doc →
commit+version → enregistre l'asset en need-review (privilégié = BLOQUÉ, aucun
sursis) → alerte `asset_created` (version + commit + doc). N'exécute jamais l'asset.
- Alerte `asset_created`. 3 tests (dépôt git temp). 73 tests, 0 vuln.
## [0.25.0] — 2026-06-23 — début Phase 5 (auto-extension)
### Added
- `src/autoext/artifact-writer.ts` : écriture d'un asset auto-créé (artefact +
doc générée depuis le gabarit), chemins sanitizés (anti-traversée), version
semver validée. `slugify`, `artifactRelPath`, `docRelPath`, `renderDoc`,
`writeArtifact`. 5 tests (dépôt temp).
## [0.24.0] — 2026-06-23 — fin Phase 4 (UI v1 : chat + review)
### Added
- UI : vue **Review** (`web/src/pages/Review.tsx`) — liste des assets en attente,
approuver / refuser (confirmation sur refus, palier coloré), refresh, 401→logout.
- Backend sert le **SPA same-origin** (`@fastify/static` 9.1.3 + fallback routes
client) si `CHLOVA_WEB_ROOT` présent ; config `webRoot`.
### Changed
- Dockerfile multi-stage : build **web + API** (contexte = racine dépôt), image
embarque le SPA (`/app/web`, `CHLOVA_WEB_ROOT` par défaut). Compose : contexte
`..`, `dockerfile: orchestrator/Dockerfile`, image `chlova/backend:0.2.0`.
Compose revalidé. 65 tests, 0 vuln.
## [0.23.0] — 2026-06-23
### Added
- UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via
+3 -1
View File
@@ -17,7 +17,9 @@ Ollama **cloud**.
|---|---|
| `docs/` | Architecture, sécurité, versioning, paliers de risque, gabarit d'asset |
| `infra/` | docker-compose de la stack + socket-proxy + notes réseau |
| `orchestrator/` | Le cerveau (TypeScript/Node, Fastify) |
| `orchestrator/` | Le cerveau (TypeScript/Node, Fastify) + API |
| `web/` | UI web/PWA (React/Vite/Tailwind) — chat, review, voix |
| `mobile/` | App mobile (React Native / Expo) — chat, review |
| `workflows-n8n/` | Exports JSON des workflows (le dépôt fait foi) |
## Démarrage (dev)
+145
View File
@@ -0,0 +1,145 @@
# 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` + clé API + secret de passerelle
1. **Users → Add user** : `chlova`, non-admin.
2. **Environments → local → Access** : rôle le plus bas suffisant. *(CE = RBAC
par environnement, pas par stack ; verrous additionnels :
`PORTAINER_READ_ONLY=true` côté serveur MCP **et** la clé API étant celle de
`chlova`, l'accès réel est borné à ce que `chlova` peut voir.)*
3. Connecté en `chlova`**My account → Access tokens → Add token**
`PORTAINER_API_KEY` (envoyé en `X-Portainer-API-Key`, cloisonne l'accès).
4. **`PORTAINER_MCP_AUTH_TOKEN`** = secret de **passerelle** au choix, **≥ 32
caractères** (`openssl rand -hex 32`) sinon le serveur MCP refuse de démarrer.
Même valeur dans le stack `mcp-portainer` et côté backend.
### 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` | secret de passerelle (§3a.4) | **oui** |
| `PORTAINER_API_KEY` | clé API Portainer de chlova (§3a.3) | **oui** |
| `CHLOVA_ADMIN_USER` | ex. `kantin` | non |
| `CHLOVA_ADMIN_PASSWORD_HASH` | (§4) | **oui** |
| `CHLOVA_TOTP_SECRET` | (§4) | **oui** |
| `CHLOVA_JWT_SECRET` | (§4) | **oui** |
> **Stack pré-créé.** Le stack `chlova` est déjà déployé en GitOps (depuis
> `http://gitea:3000/k.petit/chlova.git`, `infra/docker-compose.prod.yml`) avec
> des **placeholders** `CHANGEME` sur les 7 secrets. Les conteneurs `backend` et
> `mcp-portainer` redémarrent en boucle tant que les vraies valeurs ne sont pas
> mises. **Pour finaliser : Stacks → chlova → Editor / Environment variables →
> remplacer les 7 valeurs → Update the stack** (pas besoin de recréer).
Sinon, pour (re)créer à la main — **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`).
Le payload ne contient aucun secret.
## Reste à faire (Phase 4+)
- Auto-extension : CHLOVA génère commit + version + doc d'un asset **avant**
de le passer en need-review (Phase 4).
## Auto-extension (Phase 5 — implémenté)
Quand aucune capacité n'existe, l'agent appelle l'outil **sanctionné**
`chlova.propose_asset` (`src/autoext/`) qui :
1. écrit l'artefact (workflow/outil) + sa doc (gabarit) dans le dépôt ;
2. **commit + versionne** (commit ciblé, jamais `git add -A`) ;
3. enregistre l'asset en need-review (privilégié → **BLOQUÉ**, aucun sursis) ;
4. émet l'alerte `asset_created` (version + commit + doc).
L'asset n'est **jamais exécuté** par ce canal : il attend la review (/approve).
Un outil sanctionné est autorisé par le gatekeeper mais **audité** ; il ne touche
pas l'infra. Désactivé par défaut (`CHLOVA_AUTOEXT_ENABLED=false`) ; requiert un
dépôt git monté (`CHLOVA_REPO_ROOT`). Le LLM ne peut pas sous-classer un asset
privilégié (palier honnête imposé par le prompt + non négociable côté review).
## Reste à faire (Phase 6+)
- Voix : STT + wake-word + TTS dans l'UI (API déjà prête).
+117
View File
@@ -0,0 +1,117 @@
# 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.30.10 # >= 0.30 requis pour les modèles :cloud
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é.
# ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) — read-only P1 ─
# Parle à l'API Portainer (pas au socket Docker). Le backend s'y connecte en
# HTTP sur :17717/mcp avec le secret de passerelle (Bearer) + la clé API
# restreinte de chlova (X-Portainer-API-Key).
mcp-portainer:
image: portainer/portainer-mcp:2.42.6
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} # secret de passerelle (partagé avec le backend)
PORTAINER_MCP_ALLOWED_HOSTS: mcp-portainer:17717 # hôte par lequel le backend appelle
PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP: "1" # HTTP interne (réseau Docker privé)
PORTAINER_TLS_VERIFY: "0" # Portainer interne en HTTP : pas de TLS
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : GET/HEAD seulement (défense en profondeur)
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 (passerelle) —
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:17717/mcp}
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (= côté sidecar)
PORTAINER_API_KEY: ${PORTAINER_API_KEY:?requis} # clé API Portainer restreinte de chlova
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:
+9 -3
View File
@@ -12,7 +12,7 @@ name: chlova
services:
# ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ──────
ollama:
image: ollama/ollama:0.6.8 # TODO épingler le digest
image: ollama/ollama:0.30.10 # >= 0.30 requis pour les modèles :cloud
restart: unless-stopped
environment:
# Clé du proxy cloud — injectée depuis .env, jamais en dur.
@@ -78,19 +78,25 @@ services:
# ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ──────────────
backend:
build:
context: ../orchestrator # Dockerfile ajouté en Phase 1
image: chlova/backend:0.1.0 # tag versionné local
context: .. # racine du dépôt (image = API + SPA web)
dockerfile: orchestrator/Dockerfile
image: chlova/backend:0.2.0 # tag versionné local (API+UI)
restart: unless-stopped
env_file: ../.env
environment:
CHLOVA_ENV: ${CHLOVA_ENV:-production}
CHLOVA_PHASE: ${CHLOVA_PHASE:-1} # 1 = lecture seule (défaut) ; 2 = écriture sous review
ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} # Phase 3 : vide = alertes log-only
CHLOVA_AUTOEXT_ENABLED: ${CHLOVA_AUTOEXT_ENABLED:-false} # Phase 5 : off par défaut
CHLOVA_REPO_ROOT: ${CHLOVA_REPO_ROOT:-/app/repo}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
MCP_N8N_URL: ${MCP_N8N_URL} # endpoint MCP natif de n8n
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000}
volumes:
- chlova-data:/app/data # SQLite (table assets, P2+)
# Auto-extension (Phase 5, off par défaut) : monter le dépôt git ici pour
# que CHLOVA puisse committer les assets créés.
# - /srv/chlova-repo:/app/repo
depends_on:
- ollama
- mcp-portainer
+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).
# Contexte de build = RACINE du dépôt (voir infra/docker-compose.yml) : l'image
# embarque l'API ET le SPA web (servi same-origin).
# TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel.
FROM node:24.13-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
# ── Build du SPA (web/) ─────────────────────────────────────────────────
FROM node:24.13-bookworm-slim AS web-build
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
COPY web/ ./
RUN npm run build
# ── Build de l'API (orchestrator/) ──────────────────────────────────────
FROM node:24.13-bookworm-slim AS api-build
WORKDIR /app
COPY orchestrator/package.json orchestrator/package-lock.json* ./
RUN npm ci
COPY orchestrator/tsconfig.json orchestrator/tsconfig.build.json ./
COPY orchestrator/src ./src
RUN npm run build
# ── Runtime ─────────────────────────────────────────────────────────────
FROM node:24.13-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV CHLOVA_WEB_ROOT=/app/web
WORKDIR /app
COPY package.json package-lock.json* ./
COPY orchestrator/package.json orchestrator/package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
# Données runtime (SQLite, P2+). L'utilisateur node ne tourne pas en root.
COPY --from=api-build /app/dist ./dist
COPY --from=web-build /web/dist ./web
# Données runtime (SQLite). L'utilisateur node ne tourne pas en root.
RUN mkdir -p /app/data && chown -R node:node /app
USER node
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
+165 -2
View File
@@ -8,8 +8,9 @@
"name": "chlova-orchestrator",
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "^11.0.0",
"@fastify/cors": "11.2.0",
"@fastify/rate-limit": "11.0.0",
"@fastify/static": "^9.1.3",
"@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5",
"jose": "6.2.3",
@@ -503,6 +504,22 @@
"node": ">=18"
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -655,6 +672,53 @@
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^1.0.1",
"fastify-plugin": "^5.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -1392,6 +1456,15 @@
"fastq": "^1.17.1"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1429,6 +1502,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2076,6 +2161,23 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2556,6 +2658,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2596,6 +2707,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2621,6 +2744,30 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2752,6 +2899,22 @@
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+1
View File
@@ -19,6 +19,7 @@
"dependencies": {
"@fastify/cors": "11.2.0",
"@fastify/rate-limit": "11.0.0",
"@fastify/static": "9.1.3",
"@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5",
"jose": "6.2.3",
+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
gatekeeper jusqu'à validation humaine : à la première tentative, elle est refusée
et mise en attente de review. N'affirme jamais qu'une action mutante a réussi tant
que l'outil ne l'a pas confirmée. Les lectures restent libres.`;
que l'outil ne l'a pas confirmée. Les lectures restent libres.
Si AUCUNE capacité existante ne convient et que l'outil chlova.propose_asset est
disponible, tu peux proposer un nouvel asset (workflow n8n ou outil) : il sera
écrit, versionné, documenté et mis EN REVIEW (un asset privilégié reste bloqué
jusqu'à validation humaine). Classe honnêtement le palier de risque ; ne sous-
estime jamais un asset privilégié pour contourner la review.`;
export function buildSystemPrompt(phase: 1 | 2): string {
return `${COMMON}\n\n${phase === 2 ? PHASE_2 : PHASE_1}`;
+6
View File
@@ -17,6 +17,12 @@ export interface ToolSpec {
readOnly: boolean;
/** Palier de risque résolu (voir docs/risk-tiers.md). */
riskTier: RiskTier;
/**
* Outil interne SANCTIONNÉ de CHLOVA (ex. propose_asset). Ne touche pas
* l'infra : il STAGE des propositions reviewables (l'asset produit reste gated).
* Autorisé par le gatekeeper mais toujours audité. Jamais issu de MCP.
*/
sanctioned?: boolean;
}
export interface ToolHandle {
+10
View File
@@ -37,6 +37,16 @@ export type AlertEvent =
blocked: number;
provisional: number;
items: DigestItem[];
}
| {
kind: "asset_created";
assetId: string;
assetType: string;
version: string;
riskTier: string;
status: string;
commit: string;
doc: string;
};
export interface AlertSender {
+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.")
);
},
};
}
+40 -3
View File
@@ -35,9 +35,14 @@ const schema = z.object({
mcpN8nUrl: z.string().url(),
mcpN8nAuthToken: nonEmpty, // SECRET
// MCP Portainer
// MCP Portainer (passerelle HTTP portainer/portainer-mcp)
mcpPortainerUrl: z.string().url(),
// Secret de PASSERELLE (front-gate) partagé avec le serveur MCP : envoyé en
// `Authorization: Bearer`. N'autorise QUE l'accès à la passerelle.
portainerMcpAuthToken: nonEmpty, // SECRET
// Clé API Portainer de l'utilisateur `chlova` (restreinte) : envoyée en header
// `X-Portainer-API-Key`. C'est ELLE qui cloisonne ce que CHLOVA peut voir/faire.
portainerApiKey: nonEmpty, // SECRET
// Verrou Phase 1 : lecture seule. `false` est refusé tant que la Phase 2
// n'est pas explicitement activée (voir assertReadOnlyPhase()).
portainerReadOnly: z
@@ -45,8 +50,10 @@ const schema = z.object({
.default("true")
.transform((v) => v.toLowerCase() !== "false"),
// Surface Telegram
telegramBotToken: nonEmpty, // SECRET
// Surface Telegram (OPTIONNELLE) : si le token est absent, la surface n'est
// pas démarrée. Le boot exige alors une autre surface (API/UI) — voir
// assertHasSurface(). Permet un déploiement UI-only sans bot Telegram.
telegramBotToken: z.string().optional(), // SECRET
telegramAllowedUserIds: z
.string()
.default("")
@@ -78,6 +85,16 @@ const schema = z.object({
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
z.string().url().optional(),
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin
// Racine du SPA buildé, servi same-origin (vide = ne sert pas de SPA).
webRoot: z.string().optional(),
// Auto-extension (Phase 5) : l'agent peut créer des assets en need-review.
// Désactivé par défaut (fail-safe). Requiert un dépôt git à la racine.
autoextEnabled: z
.string()
.default("false")
.transform((v) => v.toLowerCase() === "true"),
repoRoot: z.string().default("."),
});
export type Config = z.infer<typeof schema>;
@@ -87,6 +104,7 @@ const SECRET_KEYS = new Set<keyof Config>([
"ollamaApiKey",
"mcpN8nAuthToken",
"portainerMcpAuthToken",
"portainerApiKey",
"telegramBotToken",
"alertWebhookUrl",
"adminPasswordHash",
@@ -106,6 +124,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
mcpN8nAuthToken: env.MCP_N8N_AUTH_TOKEN,
mcpPortainerUrl: env.MCP_PORTAINER_URL,
portainerMcpAuthToken: env.PORTAINER_MCP_AUTH_TOKEN,
portainerApiKey: env.PORTAINER_API_KEY,
portainerReadOnly: env.PORTAINER_READ_ONLY,
telegramBotToken: env.TELEGRAM_BOT_TOKEN,
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
@@ -116,6 +135,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
totpSecret: env.CHLOVA_TOTP_SECRET,
jwtSecret: env.CHLOVA_JWT_SECRET,
webOrigin: env.CHLOVA_WEB_ORIGIN,
webRoot: env.CHLOVA_WEB_ROOT,
autoextEnabled: env.CHLOVA_AUTOEXT_ENABLED,
repoRoot: env.CHLOVA_REPO_ROOT,
});
if (!parsed.success) {
@@ -149,6 +171,21 @@ export function assertReadOnlyPhase(cfg: Config): void {
}
}
/**
* Au moins une surface doit être active, sinon le cerveau tourne sans entrée :
* démarrage refusé (fail-closed). Surfaces possibles : Telegram (token présent)
* ou API/UI (auth complète). Évite un déploiement « muet » par mégarde.
*/
export function assertHasSurface(cfg: Config): void {
if (!cfg.telegramBotToken && !apiAuth(cfg)) {
throw new Error(
"Aucune surface configurée : fournis TELEGRAM_BOT_TOKEN (bot) " +
"ou les 4 variables API/UI (CHLOVA_ADMIN_USER/_PASSWORD_HASH/" +
"_TOTP_SECRET/_JWT_SECRET). Voir docs/deploy.md.",
);
}
}
/**
* Config d'auth de l'API si les 4 valeurs requises sont présentes, sinon null
* (API/UI désactivée — surface non exposée).
@@ -48,6 +48,11 @@ export class Gatekeeper {
* incrément du compteur d'exécution).
*/
authorizeTool(spec: ToolSpec): { allowed: boolean; reason?: string } {
// Outil interne sanctionné (ex. propose_asset) : canal contrôlé qui ne fait
// que stager des propositions reviewables (l'asset produit reste gated).
if (spec.sanctioned) {
return { allowed: true };
}
// Lecture seule : pas d'asset, pas de review.
if (spec.riskTier === "reversible" && spec.readOnly) {
return { allowed: true };
+49 -6
View File
@@ -1,5 +1,8 @@
import Fastify, { type FastifyBaseLogger } from "fastify";
import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
import fastifyStatic from "@fastify/static";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { loadConfig, assertReadOnlyPhase, assertHasSurface, redactedConfig, apiAuth } from "./config.js";
import { registerApi } from "./api/routes.js";
import { createLogger } from "./audit/log.js";
import { OllamaClient } from "./llm/ollama.js";
@@ -12,6 +15,9 @@ import { isCommand, handleReviewCommand } from "./surfaces/commands.js";
import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js";
import { startAlertScheduler } from "./alerts/scheduler.js";
import type { AlertSender } from "./alerts/types.js";
import { GitCommitter } from "./autoext/git-committer.js";
import { AutoExtensionService } from "./autoext/auto-extension.js";
import { buildProposeAssetTool } from "./autoext/tool.js";
import type { Guard, ToolHandle } from "./agent/types.js";
import { ChatService } from "./agent/chat-service.js";
import { buildSystemPrompt } from "./agent/system-prompt.js";
@@ -28,6 +34,7 @@ import { TelegramSurface } from "./surfaces/telegram.js";
async function main(): Promise<void> {
const cfg = loadConfig();
assertReadOnlyPhase(cfg);
assertHasSurface(cfg);
const logger = createLogger(cfg.logLevel);
logger.info({ config: redactedConfig(cfg) }, "CHLOVA backend démarrage");
@@ -42,7 +49,9 @@ async function main(): Promise<void> {
await registry.connect({
name: "portainer",
url: cfg.mcpPortainerUrl,
authToken: cfg.portainerMcpAuthToken,
authToken: cfg.portainerMcpAuthToken, // secret de passerelle (Bearer)
// Clé API Portainer restreinte de `chlova` : cloisonne l'accès réel.
extraHeaders: { "X-Portainer-API-Key": cfg.portainerApiKey },
});
// ── Outils + Guard, selon la phase ──────────────────────────────────────
@@ -77,6 +86,15 @@ async function main(): Promise<void> {
});
guard = new GatekeeperGuard(gatekeeper);
tools = await registry.listAllTools();
// Auto-extension (Phase 5) : ajoute l'outil sanctionné propose_asset.
if (cfg.autoextEnabled) {
const git = new GitCommitter(cfg.repoRoot);
const autoext = new AutoExtensionService(repo, git, alerts, logger, cfg.repoRoot);
tools = [...tools, buildProposeAssetTool(autoext)];
logger.warn({ repoRoot: cfg.repoRoot }, "auto-extension ACTIVÉE (propose_asset exposé)");
}
review = new ReviewService(repo, logger);
stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire
stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1
@@ -94,14 +112,18 @@ async function main(): Promise<void> {
});
const chat = new ChatService({ client, tools, guard, systemPrompt, logger });
// ── Surface Telegram (long-polling) ─────────────────────────────────────
const telegram = new TelegramSurface(
// ── Surface Telegram (long-polling) — optionnelle ───────────────────────
// Démarrée seulement si un token est fourni. Sinon, surface API/UI seule
// (garantie par assertHasSurface au boot).
const telegram = cfg.telegramBotToken
? new TelegramSurface(
{
botToken: cfg.telegramBotToken,
allowedUserIds: cfg.telegramAllowedUserIds,
},
logger,
);
)
: null;
// ── Healthcheck interne (jamais publié) ─────────────────────────────────
// pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le
@@ -128,12 +150,30 @@ async function main(): Promise<void> {
logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule");
}
// SPA same-origin : sert le build web + fallback (routes client) si présent.
if (auth && cfg.webRoot) {
const root = resolve(cfg.webRoot);
if (existsSync(root)) {
await app.register(fastifyStatic, { root });
app.setNotFoundHandler((req, reply) => {
// /api et /health restent gérés ; le reste retombe sur l'app SPA.
if (req.url.startsWith("/api") || req.url.startsWith("/health")) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
logger.info({ root }, "SPA servi same-origin");
} else {
logger.warn({ root }, "CHLOVA_WEB_ROOT introuvable — SPA non servi");
}
}
await app.listen({ host: "0.0.0.0", port: 8080 });
logger.info({ port: 8080 }, "healthcheck interne prêt");
// Arrêt propre.
const shutdown = async (): Promise<void> => {
telegram.stop();
telegram?.stop();
stopCron?.();
stopAlerts?.();
await registry.close();
@@ -145,6 +185,8 @@ async function main(): Promise<void> {
process.on("SIGINT", () => void shutdown());
// Boucle de service : commande de review (Phase 2) ou tour d'agent.
// Sans Telegram, le process reste vivant via le serveur Fastify (API/UI).
if (telegram) {
await telegram.start(async ({ userId, text }) => {
if (review && isCommand(text)) {
return handleReviewCommand(review, text);
@@ -152,6 +194,7 @@ async function main(): Promise<void> {
return chat.handle(`telegram:${userId}`, text);
});
}
}
main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
+6 -1
View File
@@ -20,6 +20,8 @@ export interface McpServerConfig {
name: string;
url: string;
authToken: string;
/** Headers additionnels (ex. X-Portainer-API-Key pour la passerelle Portainer). */
extraHeaders?: Record<string, string>;
}
interface ConnectedServer {
@@ -36,7 +38,10 @@ export class McpRegistry {
async connect(cfg: McpServerConfig): Promise<void> {
const transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
requestInit: {
headers: { authorization: `Bearer ${cfg.authToken}` },
headers: {
authorization: `Bearer ${cfg.authToken}`,
...cfg.extraHeaders,
},
},
});
const client = new Client({ name: `chlova-${cfg.name}`, version: "0.1.0" });
+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);
});
});
+31 -3
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "../src/config.js";
import {
loadConfig,
assertReadOnlyPhase,
assertHasSurface,
redactedConfig,
} from "../src/config.js";
const fullEnv = (): NodeJS.ProcessEnv => ({
OLLAMA_BASE_URL: "http://ollama:11434",
@@ -7,8 +12,9 @@ const fullEnv = (): NodeJS.ProcessEnv => ({
OLLAMA_MODEL: "qwen3:cloud",
MCP_N8N_URL: "http://mcp-n8n:3000",
MCP_N8N_AUTH_TOKEN: "secret-n8n",
MCP_PORTAINER_URL: "http://mcp-portainer:3000",
PORTAINER_MCP_AUTH_TOKEN: "secret-portainer",
MCP_PORTAINER_URL: "http://mcp-portainer:17717/mcp",
PORTAINER_MCP_AUTH_TOKEN: "secret-gate",
PORTAINER_API_KEY: "secret-portainer-apikey",
PORTAINER_READ_ONLY: "true",
TELEGRAM_BOT_TOKEN: "secret-tg",
TELEGRAM_ALLOWED_USER_IDS: "111, 222",
@@ -61,6 +67,28 @@ describe("verrou lecture seule Phase 1", () => {
});
});
describe("garde de surface (fail-closed)", () => {
it("Telegram seul suffit", () => {
expect(() => assertHasSurface(loadConfig(fullEnv()))).not.toThrow();
});
it("API/UI seule suffit (sans Telegram)", () => {
const env = fullEnv();
delete env.TELEGRAM_BOT_TOKEN;
env.CHLOVA_ADMIN_USER = "kantin";
env.CHLOVA_ADMIN_PASSWORD_HASH = "hash";
env.CHLOVA_TOTP_SECRET = "totp";
env.CHLOVA_JWT_SECRET = "jwt";
expect(() => assertHasSurface(loadConfig(env))).not.toThrow();
});
it("refuse de démarrer sans aucune surface", () => {
const env = fullEnv();
delete env.TELEGRAM_BOT_TOKEN;
expect(() => assertHasSurface(loadConfig(env))).toThrow(/surface/i);
});
});
describe("redactedConfig masque les secrets", () => {
it("ne révèle aucun secret", () => {
const red = redactedConfig(loadConfig(fullEnv()));
+11 -2
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/Review.tsx` | Need-review : approuver/refuser (v0.24.0). |
## Périmètre v1
Login → Chat → Review. Voix + app RN : phases ultérieures (API commune réutilisée).
## Voix (Phase 6)
100 % navigateur (Web Speech API), zéro backend/GPU :
- **Parler** : dictée push-to-talk (fr-FR) → envoyée à l'agent.
- **Voix ON/OFF** : lecture vocale des réponses (TTS), réglage persistant.
- **Libre** : mains-libres, déclenché par le wake-word « CHLOVA … » ; le micro se
met en pause pendant la synthèse pour éviter l'auto-écoute.
STT = Chrome/Edge (webkit) ; TTS = large support. Dégrade proprement sinon.
## Périmètre
Login → Chat (+ voix) → Review. App RN : phase ultérieure (API commune réutilisée).
+10
View File
@@ -8,6 +8,7 @@
"name": "chlova-web",
"version": "0.1.0",
"dependencies": {
"lucide-react": "^1.21.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-router-dom": "7.18.0"
@@ -4939,6 +4940,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"lucide-react": "1.21.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-router-dom": "7.18.0"
+28 -8
View File
@@ -1,24 +1,38 @@
import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom";
import { MessageSquare, ShieldCheck, LogOut, Cpu } from "lucide-react";
import { useAuth } from "./auth";
import { AppDataProvider, useAppData } from "./appdata";
import { Login } from "./pages/Login";
import { Chat } from "./pages/Chat";
import { Review } from "./pages/Review";
function Shell() {
const { logout } = useAuth();
const { phase, tools, assets } = useAppData();
const link = ({ isActive }: { isActive: boolean }): string =>
`px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
`flex items-center gap-1.5 px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
return (
<div className="min-h-dvh flex flex-col">
<header className="flex items-center gap-2 border-b border-border bg-surface px-4 py-2">
<span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span>
<header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-border bg-surface px-3 sm:px-4 py-2">
<span className="font-bold tracking-wide text-accent glow mr-1 sm:mr-2">CHLOVA</span>
<nav className="flex gap-1">
<NavLink to="/chat" className={link}>Chat</NavLink>
<NavLink to="/review" className={link}>Review</NavLink>
<NavLink to="/chat" className={link}>
<MessageSquare size={16} /> Chat
</NavLink>
<NavLink to="/review" className={link}>
<ShieldCheck size={16} /> Review
{assets.length > 0 && (
<span className="ml-1 rounded-full bg-accent px-1.5 text-xs text-bg">{assets.length}</span>
)}
</NavLink>
</nav>
<button onClick={logout} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
Déconnexion
<span className="ml-auto flex items-center gap-1.5 text-xs text-muted whitespace-nowrap" title={`Phase ${phase} · ${tools} outils`}>
<Cpu size={14} /> {phase || "…"}
<span className="hidden sm:inline">· {tools} outils</span>
</span>
<button onClick={logout} className="ml-1 sm:ml-3 shrink-0 text-muted hover:text-fg cursor-pointer" aria-label="Déconnexion" title="Déconnexion">
<LogOut size={18} />
</button>
</header>
<main className="flex-1 min-h-0">
@@ -36,7 +50,13 @@ export function App() {
const { token } = useAuth();
return (
<BrowserRouter>
{token ? <Shell /> : <Login />}
{token ? (
<AppDataProvider>
<Shell />
</AppDataProvider>
) : (
<Login />
)}
</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;
}
+85 -18
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 { api, ApiError } from "../api";
import { useSpeech } from "../useSpeech";
interface Msg {
role: "user" | "assistant";
@@ -9,27 +11,39 @@ interface Msg {
export function Chat() {
const { token, logout } = useAuth();
const speech = useSpeech();
const [messages, setMessages] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [speakReplies, setSpeakReplies] = useState(() => localStorage.getItem("chlova.speak") === "1");
const bottom = useRef<HTMLDivElement>(null);
useEffect(() => {
bottom.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, busy]);
const send = async (e: FormEvent): Promise<void> => {
e.preventDefault();
const text = input.trim();
if (!text || busy || !token) return;
const toggleSpeak = (): void => {
setSpeakReplies((v) => {
const next = !v;
localStorage.setItem("chlova.speak", next ? "1" : "0");
if (!next) speech.cancelSpeak();
return next;
});
};
const sendText = useCallback(
async (text: string): Promise<void> => {
const t = text.trim();
if (!t || busy || !token) return;
setInput("");
setError(null);
setMessages((m) => [...m, { role: "user", text }]);
setMessages((m) => [...m, { role: "user", text: t }]);
setBusy(true);
try {
const { reply } = await api.chat(token, text);
const { reply } = await api.chat(token, t);
setMessages((m) => [...m, { role: "assistant", text: reply }]);
if (speakReplies || speech.handsFree) speech.speak(reply);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
logout();
@@ -39,22 +53,38 @@ export function Chat() {
} finally {
setBusy(false);
}
},
[busy, token, speakReplies, speech, logout],
);
const submit = (e: FormEvent): void => {
e.preventDefault();
void sendText(input);
};
const mic = (): void => {
if (speech.listening && !speech.handsFree) {
speech.stopListening();
return;
}
speech.listen((text) => void sendText(text)); // dicter → envoyer
};
const toggleHandsFree = (): void => {
if (speech.handsFree) speech.stopHandsFree();
else speech.startHandsFree((text) => void sendText(text)); // « CHLOVA … » → envoyer
};
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{messages.length === 0 && (
<p className="text-muted text-sm">Pose une question à CHLOVA</p>
)}
{messages.length === 0 && <p className="text-muted text-sm">Pose une question à CHLOVA</p>}
{messages.map((m, i) => (
<div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
<div
className={
"max-w-[80%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm " +
(m.role === "user"
? "bg-surface-2 border border-accent/40"
: "bg-surface border border-border font-mono")
(m.role === "user" ? "bg-surface-2 border border-accent/40" : "bg-surface border border-border font-mono")
}
>
{m.text}
@@ -62,24 +92,61 @@ export function Chat() {
</div>
))}
{busy && <p className="text-muted text-sm animate-pulse">CHLOVA réfléchit</p>}
{speech.handsFree && !busy && !speech.speaking && (
<p className="text-accent text-sm">Mains libres dis « CHLOVA »</p>
)}
{speech.speaking && <p className="text-accent text-sm">Lecture vocale</p>}
{error && <p role="alert" className="text-danger text-sm">{error}</p>}
<div ref={bottom} />
</div>
<form onSubmit={send} className="flex gap-2 border-t border-border bg-surface p-3">
<form onSubmit={submit} className="flex items-center gap-1.5 border-t border-border bg-surface p-2 sm: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={`shrink-0 rounded-md border px-2.5 sm:px-3 py-2 cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
>
{speakReplies ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
)}
<input
className="flex-1 rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent"
placeholder="Message…"
className="flex-1 min-w-0 rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent"
placeholder={speech.listening ? "Écoute…" : "Message…"}
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={busy}
/>
{speech.sttSupported && !speech.handsFree && (
<button
type="button"
onClick={mic}
aria-label={speech.listening ? "Arrêter le micro" : "Parler"}
className={`shrink-0 rounded-md border px-2.5 sm: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={`shrink-0 rounded-md border px-2.5 sm:px-3 py-2 cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
>
<Radio size={18} />
</button>
)}
<button
type="submit"
disabled={busy || !input.trim()}
className="rounded-md bg-accent px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
aria-label="Envoyer"
className="flex shrink-0 items-center gap-1.5 rounded-md bg-accent px-3 sm:px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
>
Envoyer
<Send size={16} /> <span className="hidden sm:inline">Envoyer</span>
</button>
</form>
</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() {
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="flex gap-2 w-full sm:w-auto sm:ml-auto">
<button
onClick={() => void approve(a.id)}
className="flex flex-1 sm:flex-none items-center justify-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 flex-1 sm:flex-none items-center justify-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,
};
}