Compare commits

...

5 Commits

Author SHA1 Message Date
Kantin-Petit 0da5e2aba1 feat(chat): conversations persistantes + mémoire multi-tour (v0.36.0)
Store SQLite conversations/messages (propriété par actor, fenêtre 20),
historique rejoué au LLM (runAgentTurn history), ChatService persiste et
renvoie conversationId. API GET/DELETE /conversations + chat avec
conversationId. UI Chat: sidebar conversations (drawer mobile), nouvelle,
reprise, suppression. docs/conversations.md. 83 tests verts, build web vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
2026-06-23 23:25:42 +02:00
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
20 changed files with 784 additions and 152 deletions
+13 -5
View File
@@ -7,7 +7,10 @@
# ollama.com. Joignable uniquement sur le réseau Docker interne. # ollama.com. Joignable uniquement sur le réseau Docker interne.
OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_API_KEY= # SECRET — clé API Ollama cloud 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) ──────────────────────────── # ── MCP n8n : NATIF (instance n8n ≥ 2.18.4) ────────────────────────────
# Pas de conteneur dédié : n8n sert son propre MCP. Activer côté instance # Pas de conteneur dédié : n8n sert son propre MCP. Activer côté instance
@@ -19,13 +22,18 @@ 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_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_N8N_AUTH_TOKEN= # SECRET — "MCP Access Token" n8n (Bearer)
# ── MCP Portainer (portainer/portainer-mcp) ──────────────────────────── # ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) ────────────
PORTAINER_URL=https://portainer:9443 # interne uniquement PORTAINER_URL=http://portainer:9000 # API Portainer, interne (même hôte)
PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RESTREINTE # 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 # Phase 1 : DOIT rester true (le boot échoue sinon). Phase 2 : peut passer false
# pour autoriser les écritures Portainer (sous gatekeeper + review). # pour autoriser les écritures Portainer (sous gatekeeper + review).
PORTAINER_READ_ONLY=true PORTAINER_READ_ONLY=true
MCP_PORTAINER_URL=http://mcp-portainer:3000 MCP_PORTAINER_URL=http://mcp-portainer:17717/mcp
# ── Surface Telegram (OPTIONNELLE) ───────────────────────────────────── # ── Surface Telegram (OPTIONNELLE) ─────────────────────────────────────
# Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une # Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une
+66
View File
@@ -6,6 +6,72 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [Unreleased]
## [0.36.0] — 2026-06-23 — conversations persistantes (mémoire multi-tour)
### Added
- **`conversations/store.ts`** : store SQLite (conversations + messages) avec
propriété par `actor`, fenêtre de contexte (`HISTORY_WINDOW=20`), titres auto.
- **Historique rejoué au LLM** : `runAgentTurn({ history })` ; `ChatService`
charge l'historique récent, persiste user+assistant, renvoie `conversationId`.
- API : `GET/DELETE /api/conversations`, `GET /api/conversations/:id` ;
`POST /api/chat` accepte `conversationId` et renvoie `{ reply, conversationId }`.
- **UI Chat** : barre latérale des conversations (drawer mobile / fixe md+),
Nouvelle, reprise, suppression.
- `docs/conversations.md`. Telegram : `conversationId` stable par utilisateur.
- Tests : store + mémoire multi-tour + isolation par acteur (83 tests verts).
### Changed
- `ChatService.handle` renvoie `{ reply, conversationId }` (était `string`) ;
appelants (API, Telegram) adaptés.
### Notes
- Mobile (Expo) : chat encore sans état (branchement conversations = suivi).
## [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) ## [0.33.0] — 2026-06-23 — infra locale déployée (gitea + n8n-chlova)
### Added ### Added
- **`infra/gitea/docker-compose.yml`** : serveur git Gitea 1.26.4 sur l'hôte - **`infra/gitea/docker-compose.yml`** : serveur git Gitea 1.26.4 sur l'hôte
+54
View File
@@ -0,0 +1,54 @@
# Conversations persistantes (mémoire multi-tour)
CHLOVA stocke chaque conversation et **rejoue l'historique récent** au LLM, pour
qu'il garde le contexte d'un message à l'autre et que l'utilisateur puisse
**reprendre** une conversation plus tard.
## Modèle
SQLite (`node:sqlite`, même fichier que la table assets — `CHLOVA_DB_PATH`).
| Table | Colonnes |
|---|---|
| `conversations` | `id` (uuid), `actor`, `title`, `created_at`, `updated_at` |
| `messages` | `id`, `conversation_id`, `role` (`user`/`assistant`), `content`, `ts` |
- **Propriété** : une conversation appartient à un `actor` (API = `api:owner`,
Telegram = `telegram:<userId>`). Accès à une conversation d'un autre acteur
refusé (`ForbiddenConversationError` → HTTP 403 / 404 selon l'endpoint).
- **Titre** : dérivé du 1er message (tronqué à 60 car.).
- **Fenêtre de contexte** : les `HISTORY_WINDOW` (= 20) derniers messages sont
rejoués au LLM (`ChatService``runAgentTurn({ history })`). Au-delà, l'ancien
n'est pas envoyé (pas de résumé/troncature par tokens en v1 — amélioration
possible si les conversations deviennent longues).
## Flux
1. `POST /api/chat { message, conversationId? }` :
- sans `conversationId` → nouvelle conversation créée, son id renvoyé ;
- avec `conversationId` → historique chargé + rejoué, message ajouté.
- réponse : `{ reply, conversationId }`.
2. `GET /api/conversations` → liste (id, titre, dates) de l'acteur, par récence.
3. `GET /api/conversations/:id` → messages complets (reprise).
4. `DELETE /api/conversations/:id` → suppression (messages + conversation).
## UI
Onglet **Chat** : barre latérale des conversations (drawer sur mobile, fixe en
`md+`), bouton **Nouvelle**, sélection pour reprendre, suppression. L'envoi
transmet le `conversationId` courant ; une nouvelle conversation rafraîchit la
liste.
## Surfaces
- **API/UI** : pleinement câblé (création, reprise, suppression).
- **Telegram** (si activé) : `conversationId` stable = `telegram:<userId>`
mémoire continue par utilisateur.
- **Mobile (Expo)** : chat encore sans état — branchement des conversations à
faire (réutilise les mêmes endpoints).
## Sécurité / vie privée
Les messages sont stockés en clair dans la base (volume `chlova-data`). Pas de
secret n'y transite normalement (l'agent manipule des références). La base n'est
jamais exposée ; seul le backend y accède.
+18 -6
View File
@@ -58,13 +58,17 @@ git push gitea main # auth : <admin> + token de l'étape 1.3
> Le MCP Portainer n'expose pas la gestion users/tokens : étapes UI. Principe : > Le MCP Portainer n'expose pas la gestion users/tokens : étapes UI. Principe :
> CHLOVA n'accède qu'à ses ressources, tokens à portée minimale. > CHLOVA n'accède qu'à ses ressources, tokens à portée minimale.
### 3a. Portainer — user `chlova` + token ### 3a. Portainer — user `chlova` + clé API + secret de passerelle
1. **Users → Add user** : `chlova`, non-admin. 1. **Users → Add user** : `chlova`, non-admin.
2. **Environments → local → Access** : rôle le plus bas suffisant. *(CE = RBAC 2. **Environments → local → Access** : rôle le plus bas suffisant. *(CE = RBAC
par environnement, pas par stack ; le vrai verrou Phase 1 reste par environnement, pas par stack ; verrous additionnels :
`PORTAINER_READ_ONLY=true`.)* `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** 3. Connecté en `chlova`**My account → Access tokens → Add token**
`PORTAINER_MCP_AUTH_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 ### 3b. n8n-chlova — MCP token
1. `https://n8n-chlova.pogoo.app` → créer le compte propriétaire. 1. `https://n8n-chlova.pogoo.app` → créer le compte propriétaire.
@@ -96,13 +100,21 @@ l'opérateur, jamais par l'agent).
| `OLLAMA_API_KEY` | clé Ollama cloud | **oui** | | `OLLAMA_API_KEY` | clé Ollama cloud | **oui** |
| `OLLAMA_MODEL` | `qwen3:cloud` | non | | `OLLAMA_MODEL` | `qwen3:cloud` | non |
| `MCP_N8N_AUTH_TOKEN` | token MCP n8n-chlova (§3b) | **oui** | | `MCP_N8N_AUTH_TOKEN` | token MCP n8n-chlova (§3b) | **oui** |
| `PORTAINER_MCP_AUTH_TOKEN` | token user chlova (§3a) | **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_USER` | ex. `kantin` | non |
| `CHLOVA_ADMIN_PASSWORD_HASH` | (§4) | **oui** | | `CHLOVA_ADMIN_PASSWORD_HASH` | (§4) | **oui** |
| `CHLOVA_TOTP_SECRET` | (§4) | **oui** | | `CHLOVA_TOTP_SECRET` | (§4) | **oui** |
| `CHLOVA_JWT_SECRET` | (§4) | **oui** | | `CHLOVA_JWT_SECRET` | (§4) | **oui** |
**Stacks → Add stack → Git repository** : > **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 - Repository URL = `http://gitea:3000/<admin>/chlova.git` (clone interne) ou
`https://git.pogoo.app/<admin>/chlova.git`. `https://git.pogoo.app/<admin>/chlova.git`.
- Authentication = user gitea + token (§1.3). - Authentication = user gitea + token (§1.3).
+15 -38
View File
@@ -20,7 +20,7 @@ name: chlova
services: services:
# ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ────── # ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ──────
ollama: ollama:
image: ollama/ollama:0.6.8 image: ollama/ollama:0.30.10 # >= 0.30 requis pour les modèles :cloud
restart: unless-stopped restart: unless-stopped
environment: environment:
OLLAMA_API_KEY: ${OLLAMA_API_KEY:?OLLAMA_API_KEY requis} OLLAMA_API_KEY: ${OLLAMA_API_KEY:?OLLAMA_API_KEY requis}
@@ -32,44 +32,20 @@ services:
- chlova-egress - chlova-egress
# AUCUN port publié : Ollama n'a pas d'auth native, jamais exposé. # AUCUN port publié : Ollama n'a pas d'auth native, jamais exposé.
# ── socket-proxy : accès Docker filtré, LECTURE SEULE (Phase 1) ──────── # ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) — read-only P1
socket-proxy: # Parle à l'API Portainer (pas au socket Docker). Le backend s'y connecte en
image: tecnativa/docker-socket-proxy:0.3.0 # HTTP sur :17717/mcp avec le secret de passerelle (Bearer) + la clé API
restart: unless-stopped # restreinte de chlova (X-Portainer-API-Key).
environment:
CONTAINERS: 1
IMAGES: 1
NETWORKS: 1
VOLUMES: 1
SERVICES: 1
TASKS: 1
NODES: 1
INFO: 1
VERSION: 1
POST: 0
EXEC: 0
AUTH: 0
SECRETS: 0
CONFIGS: 0
BUILD: 0
COMMIT: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- chlova-internal
# AUCUN port publié.
# ── MCP Portainer (portainer/portainer-mcp) — read-only en Phase 1 ──────
mcp-portainer: mcp-portainer:
image: portainer/portainer-mcp:0.6.0 image: portainer/portainer-mcp:2.42.6
restart: unless-stopped restart: unless-stopped
environment: environment:
PORTAINER_URL: ${PORTAINER_URL:-http://portainer:9000} # interne : Portainer sur le même hôte (local), réseau proxy PORTAINER_URL: ${PORTAINER_URL:-http://portainer:9000} # interne : Portainer sur le même hôte (local), réseau proxy
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # token user chlova restreint PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (partagé avec le backend)
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : NE PAS passer à false PORTAINER_MCP_ALLOWED_HOSTS: mcp-portainer:17717 # hôte par lequel le backend appelle
DOCKER_HOST: tcp://socket-proxy:2375 PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP: "1" # HTTP interne (réseau Docker privé)
depends_on: PORTAINER_TLS_VERIFY: "0" # Portainer interne en HTTP : pas de TLS
- socket-proxy PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : GET/HEAD seulement (défense en profondeur)
networks: networks:
- chlova-internal # joignable par le backend - chlova-internal # joignable par le backend
- proxy # joint le serveur Portainer (http://portainer:9000) - proxy # joint le serveur Portainer (http://portainer:9000)
@@ -95,9 +71,10 @@ services:
# — MCP n8n (natif, interne via réseau proxy) — # — MCP n8n (natif, interne via réseau proxy) —
MCP_N8N_URL: ${MCP_N8N_URL:-http://n8n-chlova:5678/mcp-server/http} MCP_N8N_URL: ${MCP_N8N_URL:-http://n8n-chlova:5678/mcp-server/http}
MCP_N8N_AUTH_TOKEN: ${MCP_N8N_AUTH_TOKEN:?requis} MCP_N8N_AUTH_TOKEN: ${MCP_N8N_AUTH_TOKEN:?requis}
# — MCP Portainer (sidecar) — # — MCP Portainer (passerelle) —
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000} MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:17717/mcp}
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} 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} PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true}
# — Alertes (Phase 3) : vide = log-only — # — Alertes (Phase 3) : vide = log-only —
ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-}
+1 -1
View File
@@ -12,7 +12,7 @@ name: chlova
services: services:
# ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ────── # ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ──────
ollama: 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 restart: unless-stopped
environment: environment:
# Clé du proxy cloud — injectée depuis .env, jamais en dur. # Clé du proxy cloud — injectée depuis .env, jamais en dur.
+49 -2
View File
@@ -1,12 +1,17 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import { OllamaClient } from "../llm/ollama.js"; import { OllamaClient } from "../llm/ollama.js";
import type { OllamaMessage } from "../llm/ollama.js";
import { runAgentTurn } from "./loop.js"; import { runAgentTurn } from "./loop.js";
import type { Guard, ToolHandle } from "./types.js"; import type { Guard, ToolHandle } from "./types.js";
import { ConversationStore } from "../conversations/store.js";
/** /**
* Service de conversation : un tour d'agent par message. Partagé par toutes les * Service de conversation : un tour d'agent par message. Partagé par toutes les
* surfaces (Telegram, API/UI) pour garantir le MÊME comportement et le même * surfaces (Telegram, API/UI) pour garantir le MÊME comportement et le même
* contrôle (Guard, audit) quelle que soit l'entrée. * contrôle (Guard, audit).
*
* Si un `store` est fourni, les conversations sont PERSISTÉES et l'historique
* récent est rejoué au LLM (mémoire multi-tour). Sans store : sans état.
*/ */
export interface ChatServiceDeps { export interface ChatServiceDeps {
client: OllamaClient; client: OllamaClient;
@@ -14,16 +19,58 @@ export interface ChatServiceDeps {
guard: Guard; guard: Guard;
systemPrompt: string; systemPrompt: string;
logger: Logger; logger: Logger;
store?: ConversationStore;
} }
export interface ChatResult {
reply: string;
conversationId: string | null;
}
/** Refus d'accès à une conversation appartenant à un autre acteur. */
export class ForbiddenConversationError extends Error {}
export class ChatService { export class ChatService {
constructor(private readonly deps: ChatServiceDeps) {} constructor(private readonly deps: ChatServiceDeps) {}
async handle(actor: string, text: string): Promise<string> { async handle(actor: string, text: string, conversationId?: string): Promise<ChatResult> {
const { store } = this.deps;
// Sans persistance : comportement sans état (rétrocompat).
if (!store) {
const reply = await this.runTurn(actor, text, []);
return { reply, conversationId: null };
}
// Résolution / création de la conversation, avec contrôle de propriété.
let convId = conversationId;
if (convId) {
const owner = store.ownerOf(convId);
if (owner && owner !== actor) {
throw new ForbiddenConversationError("conversation d'un autre utilisateur");
}
if (!owner) store.ensure(convId, actor, text);
} else {
convId = store.create(actor, text);
}
// Historique AVANT d'ajouter le nouveau message (tours précédents).
const history: OllamaMessage[] = store
.recent(convId)
.map((m) => ({ role: m.role, content: m.content }));
store.append(convId, "user", text);
const reply = await this.runTurn(actor, text, history);
store.append(convId, "assistant", reply);
return { reply, conversationId: convId };
}
private async runTurn(actor: string, text: string, history: OllamaMessage[]): Promise<string> {
const { reply, steps } = await runAgentTurn({ const { reply, steps } = await runAgentTurn({
client: this.deps.client, client: this.deps.client,
system: this.deps.systemPrompt, system: this.deps.systemPrompt,
userText: text, userText: text,
history,
tools: this.deps.tools, tools: this.deps.tools,
actor, actor,
guard: this.deps.guard, guard: this.deps.guard,
+3
View File
@@ -15,6 +15,8 @@ export interface AgentTurnInput {
client: OllamaClient; client: OllamaClient;
system: string; system: string;
userText: string; userText: string;
/** Historique récent (tours précédents) rejoué avant le nouveau message. */
history?: OllamaMessage[];
tools: ToolHandle[]; tools: ToolHandle[];
/** Identité de l'appelant (ex. id utilisateur Telegram). */ /** Identité de l'appelant (ex. id utilisateur Telegram). */
actor: string; actor: string;
@@ -50,6 +52,7 @@ export async function runAgentTurn(
const messages: OllamaMessage[] = [ const messages: OllamaMessage[] = [
{ role: "system", content: input.system }, { role: "system", content: input.system },
...(input.history ?? []),
{ role: "user", content: input.userText }, { role: "user", content: input.userText },
]; ];
+44 -4
View File
@@ -1,10 +1,14 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
import type { ChatService } from "../agent/chat-service.js"; import { ChatService, ForbiddenConversationError } from "../agent/chat-service.js";
import type { ReviewService } from "../gatekeeper/review.js"; import type { ReviewService } from "../gatekeeper/review.js";
import type { ConversationStore } from "../conversations/store.js";
import { login, verifyJwt, type AuthConfig } from "./auth.js"; import { login, verifyJwt, type AuthConfig } from "./auth.js";
/** Acteur unique de la surface API (propriétaire). */
const API_ACTOR = "api:owner";
/** /**
* API HTTP de la surface exposée (Phase 4). JWT obligatoire sauf /api/auth/login. * API HTTP de la surface exposée (Phase 4). JWT obligatoire sauf /api/auth/login.
* Réutilise ChatService (même comportement que Telegram) et ReviewService. * Réutilise ChatService (même comportement que Telegram) et ReviewService.
@@ -16,6 +20,7 @@ export interface ApiDeps {
auth: AuthConfig; auth: AuthConfig;
chat: ChatService; chat: ChatService;
review: ReviewService | null; review: ReviewService | null;
conversations: ConversationStore | null;
state: () => Record<string, unknown>; state: () => Record<string, unknown>;
webOrigin?: string | undefined; webOrigin?: string | undefined;
} }
@@ -23,7 +28,7 @@ export interface ApiDeps {
export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise<void> { export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise<void> {
await app.register(cors, { await app.register(cors, {
origin: deps.webOrigin ?? false, origin: deps.webOrigin ?? false,
methods: ["GET", "POST"], methods: ["GET", "POST", "DELETE"],
allowedHeaders: ["content-type", "authorization"], allowedHeaders: ["content-type", "authorization"],
}); });
await app.register(rateLimit, { max: 120, timeWindow: "1 minute" }); await app.register(rateLimit, { max: 120, timeWindow: "1 minute" });
@@ -58,8 +63,43 @@ export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise<
const body = (req.body ?? {}) as Record<string, unknown>; const body = (req.body ?? {}) as Record<string, unknown>;
const message = String(body.message ?? "").trim(); const message = String(body.message ?? "").trim();
if (!message) return reply.code(400).send({ error: "message vide" }); if (!message) return reply.code(400).send({ error: "message vide" });
const replyText = await deps.chat.handle("api:owner", message); const conversationId =
return { reply: replyText }; typeof body.conversationId === "string" && body.conversationId
? body.conversationId
: undefined;
try {
const res = await deps.chat.handle(API_ACTOR, message, conversationId);
return { reply: res.reply, conversationId: res.conversationId };
} catch (err) {
if (err instanceof ForbiddenConversationError) {
return reply.code(403).send({ error: "conversation interdite" });
}
throw err;
}
});
// ── Conversations (historique persistant) ────────────────────────────────
app.get("/api/conversations", { preHandler: requireAuth }, async () => {
return { conversations: deps.conversations ? deps.conversations.list(API_ACTOR) : [] };
});
app.get("/api/conversations/:id", { preHandler: requireAuth }, async (req, reply) => {
if (!deps.conversations) return reply.code(404).send({ error: "indisponible" });
const id = (req.params as { id: string }).id;
if (deps.conversations.ownerOf(id) !== API_ACTOR) {
return reply.code(404).send({ error: "conversation inconnue" });
}
return { messages: deps.conversations.messages(id) };
});
app.delete("/api/conversations/:id", { preHandler: requireAuth }, async (req, reply) => {
if (!deps.conversations) return reply.code(404).send({ error: "indisponible" });
const id = (req.params as { id: string }).id;
if (deps.conversations.ownerOf(id) !== API_ACTOR) {
return reply.code(404).send({ error: "conversation inconnue" });
}
deps.conversations.delete(id);
return { ok: true };
}); });
// ── Review ──────────────────────────────────────────────────────────────── // ── Review ────────────────────────────────────────────────────────────────
+8 -1
View File
@@ -35,9 +35,14 @@ const schema = z.object({
mcpN8nUrl: z.string().url(), mcpN8nUrl: z.string().url(),
mcpN8nAuthToken: nonEmpty, // SECRET mcpN8nAuthToken: nonEmpty, // SECRET
// MCP Portainer // MCP Portainer (passerelle HTTP portainer/portainer-mcp)
mcpPortainerUrl: z.string().url(), 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 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 // Verrou Phase 1 : lecture seule. `false` est refusé tant que la Phase 2
// n'est pas explicitement activée (voir assertReadOnlyPhase()). // n'est pas explicitement activée (voir assertReadOnlyPhase()).
portainerReadOnly: z portainerReadOnly: z
@@ -99,6 +104,7 @@ const SECRET_KEYS = new Set<keyof Config>([
"ollamaApiKey", "ollamaApiKey",
"mcpN8nAuthToken", "mcpN8nAuthToken",
"portainerMcpAuthToken", "portainerMcpAuthToken",
"portainerApiKey",
"telegramBotToken", "telegramBotToken",
"alertWebhookUrl", "alertWebhookUrl",
"adminPasswordHash", "adminPasswordHash",
@@ -118,6 +124,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
mcpN8nAuthToken: env.MCP_N8N_AUTH_TOKEN, mcpN8nAuthToken: env.MCP_N8N_AUTH_TOKEN,
mcpPortainerUrl: env.MCP_PORTAINER_URL, mcpPortainerUrl: env.MCP_PORTAINER_URL,
portainerMcpAuthToken: env.PORTAINER_MCP_AUTH_TOKEN, portainerMcpAuthToken: env.PORTAINER_MCP_AUTH_TOKEN,
portainerApiKey: env.PORTAINER_API_KEY,
portainerReadOnly: env.PORTAINER_READ_ONLY, portainerReadOnly: env.PORTAINER_READ_ONLY,
telegramBotToken: env.TELEGRAM_BOT_TOKEN, telegramBotToken: env.TELEGRAM_BOT_TOKEN,
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS, telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
+162
View File
@@ -0,0 +1,162 @@
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";
/**
* Persistance des conversations (mémoire multi-tour + reprise ultérieure).
*
* SQLite via `node:sqlite` (intégré, zéro dépendance native), même fichier que
* la table assets. Une conversation appartient à un `actor` ; ses messages sont
* rejoués au LLM (fenêtre récente) pour qu'il garde le contexte. Voir
* docs/conversations.md.
*/
export type Role = "user" | "assistant";
export interface ConversationMeta {
id: string;
title: string;
createdAt: number;
updatedAt: number;
}
export interface Message {
role: Role;
content: string;
ts: number;
}
/** Nombre de messages récents rejoués au LLM (fenêtre de contexte). */
export const HISTORY_WINDOW = 20;
interface ConvRow {
id: string;
actor: string;
title: string;
created_at: number;
updated_at: number;
}
interface MsgRow {
role: string;
content: string;
ts: number;
}
function title(text: string): string {
const t = text.trim().replace(/\s+/g, " ");
return t.length > 60 ? `${t.slice(0, 57)}` : t || "Nouvelle conversation";
}
export class ConversationStore {
private readonly db: DatabaseSync;
constructor(dbPath: string) {
this.db = new DatabaseSync(dbPath);
this.db.exec("PRAGMA journal_mode = WAL;");
this.migrate();
}
private migrate(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
title TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_actor ON conversations(actor, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversation_id, id);
`);
}
/** Crée une conversation et retourne son id. */
create(actor: string, firstText: string, now = Date.now()): string {
const id = randomUUID();
this.db
.prepare(
"INSERT INTO conversations (id, actor, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
)
.run(id, actor, title(firstText), now, now);
return id;
}
/** Garantit l'existence d'une conversation à id fixe (surfaces type Telegram). */
ensure(id: string, actor: string, firstText: string, now = Date.now()): void {
if (this.ownerOf(id)) return;
this.db
.prepare(
"INSERT INTO conversations (id, actor, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
)
.run(id, actor, title(firstText), now, now);
}
/** Retourne le propriétaire (actor) d'une conversation, ou null si inconnue. */
ownerOf(id: string): string | null {
const row = this.db
.prepare("SELECT actor FROM conversations WHERE id = ?")
.get(id) as { actor: string } | undefined;
return row?.actor ?? null;
}
list(actor: string): ConversationMeta[] {
const rows = this.db
.prepare(
"SELECT id, actor, title, created_at, updated_at FROM conversations WHERE actor = ? ORDER BY updated_at DESC",
)
.all(actor) as unknown as ConvRow[];
return rows.map((r) => ({
id: r.id,
title: r.title,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
messages(conversationId: string): Message[] {
const rows = this.db
.prepare(
"SELECT role, content, ts FROM messages WHERE conversation_id = ? ORDER BY id ASC",
)
.all(conversationId) as unknown as MsgRow[];
return rows.map((r) => ({ role: r.role as Role, content: r.content, ts: r.ts }));
}
/** Derniers messages (fenêtre de contexte), dans l'ordre chronologique. */
recent(conversationId: string, limit = HISTORY_WINDOW): Message[] {
const rows = this.db
.prepare(
"SELECT role, content, ts FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?",
)
.all(conversationId, limit) as unknown as MsgRow[];
return rows
.map((r) => ({ role: r.role as Role, content: r.content, ts: r.ts }))
.reverse();
}
append(conversationId: string, role: Role, content: string, now = Date.now()): void {
this.db
.prepare(
"INSERT INTO messages (conversation_id, role, content, ts) VALUES (?, ?, ?, ?)",
)
.run(conversationId, role, content, now);
this.db
.prepare("UPDATE conversations SET updated_at = ? WHERE id = ?")
.run(now, conversationId);
}
delete(id: string): void {
this.db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(id);
this.db.prepare("DELETE FROM conversations WHERE id = ?").run(id);
}
close(): void {
this.db.close();
}
}
+13 -3
View File
@@ -20,6 +20,7 @@ import { AutoExtensionService } from "./autoext/auto-extension.js";
import { buildProposeAssetTool } from "./autoext/tool.js"; import { buildProposeAssetTool } from "./autoext/tool.js";
import type { Guard, ToolHandle } from "./agent/types.js"; import type { Guard, ToolHandle } from "./agent/types.js";
import { ChatService } from "./agent/chat-service.js"; import { ChatService } from "./agent/chat-service.js";
import { ConversationStore } from "./conversations/store.js";
import { buildSystemPrompt } from "./agent/system-prompt.js"; import { buildSystemPrompt } from "./agent/system-prompt.js";
import { TelegramSurface } from "./surfaces/telegram.js"; import { TelegramSurface } from "./surfaces/telegram.js";
@@ -49,7 +50,9 @@ async function main(): Promise<void> {
await registry.connect({ await registry.connect({
name: "portainer", name: "portainer",
url: cfg.mcpPortainerUrl, 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 ────────────────────────────────────── // ── Outils + Guard, selon la phase ──────────────────────────────────────
@@ -108,7 +111,9 @@ async function main(): Promise<void> {
apiKey: cfg.ollamaApiKey, apiKey: cfg.ollamaApiKey,
model: cfg.ollamaModel, model: cfg.ollamaModel,
}); });
const chat = new ChatService({ client, tools, guard, systemPrompt, logger }); // Conversations persistées (mémoire multi-tour + reprise), même fichier SQLite.
const conversations = new ConversationStore(cfg.dbPath);
const chat = new ChatService({ client, tools, guard, systemPrompt, logger, store: conversations });
// ── Surface Telegram (long-polling) — optionnelle ─────────────────────── // ── Surface Telegram (long-polling) — optionnelle ───────────────────────
// Démarrée seulement si un token est fourni. Sinon, surface API/UI seule // Démarrée seulement si un token est fourni. Sinon, surface API/UI seule
@@ -140,6 +145,7 @@ async function main(): Promise<void> {
auth, auth,
chat, chat,
review, review,
conversations,
state: stateOf, state: stateOf,
webOrigin: cfg.webOrigin, webOrigin: cfg.webOrigin,
}); });
@@ -177,6 +183,7 @@ async function main(): Promise<void> {
await registry.close(); await registry.close();
await app.close(); await app.close();
repo?.close(); repo?.close();
conversations.close();
process.exit(0); process.exit(0);
}; };
process.on("SIGTERM", () => void shutdown()); process.on("SIGTERM", () => void shutdown());
@@ -189,7 +196,10 @@ async function main(): Promise<void> {
if (review && isCommand(text)) { if (review && isCommand(text)) {
return handleReviewCommand(review, text); return handleReviewCommand(review, text);
} }
return chat.handle(`telegram:${userId}`, text); // conversationId stable par utilisateur Telegram → mémoire continue.
const actor = `telegram:${userId}`;
const { reply } = await chat.handle(actor, text, actor);
return reply;
}); });
} }
} }
+6 -1
View File
@@ -20,6 +20,8 @@ export interface McpServerConfig {
name: string; name: string;
url: string; url: string;
authToken: string; authToken: string;
/** Headers additionnels (ex. X-Portainer-API-Key pour la passerelle Portainer). */
extraHeaders?: Record<string, string>;
} }
interface ConnectedServer { interface ConnectedServer {
@@ -36,7 +38,10 @@ export class McpRegistry {
async connect(cfg: McpServerConfig): Promise<void> { async connect(cfg: McpServerConfig): Promise<void> {
const transport = new StreamableHTTPClientTransport(new URL(cfg.url), { const transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
requestInit: { 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" }); const client = new Client({ name: `chlova-${cfg.name}`, version: "0.1.0" });
+2 -2
View File
@@ -19,7 +19,7 @@ const auth: AuthConfig = {
// ChatService stub : pas de vrai LLM. // ChatService stub : pas de vrai LLM.
const chatStub = { const chatStub = {
handle: async (_actor: string, text: string) => `echo:${text}`, handle: async (_actor: string, text: string) => ({ reply: `echo:${text}`, conversationId: null }),
} as unknown as ChatService; } as unknown as ChatService;
let app: FastifyInstance; let app: FastifyInstance;
@@ -29,7 +29,7 @@ beforeEach(async () => {
repo = new AssetRepository(":memory:"); repo = new AssetRepository(":memory:");
const review = new ReviewService(repo, log); const review = new ReviewService(repo, log);
app = Fastify(); app = Fastify();
await registerApi(app, { auth, chat: chatStub, review, state: () => ({ phase: "2-write-review", tools: 3 }) }); await registerApi(app, { auth, chat: chatStub, review, conversations: null, state: () => ({ phase: "2-write-review", tools: 3 }) });
await app.ready(); await app.ready();
}); });
afterEach(async () => { afterEach(async () => {
+3 -2
View File
@@ -12,8 +12,9 @@ const fullEnv = (): NodeJS.ProcessEnv => ({
OLLAMA_MODEL: "qwen3:cloud", OLLAMA_MODEL: "qwen3:cloud",
MCP_N8N_URL: "http://mcp-n8n:3000", MCP_N8N_URL: "http://mcp-n8n:3000",
MCP_N8N_AUTH_TOKEN: "secret-n8n", MCP_N8N_AUTH_TOKEN: "secret-n8n",
MCP_PORTAINER_URL: "http://mcp-portainer:3000", MCP_PORTAINER_URL: "http://mcp-portainer:17717/mcp",
PORTAINER_MCP_AUTH_TOKEN: "secret-portainer", PORTAINER_MCP_AUTH_TOKEN: "secret-gate",
PORTAINER_API_KEY: "secret-portainer-apikey",
PORTAINER_READ_ONLY: "true", PORTAINER_READ_ONLY: "true",
TELEGRAM_BOT_TOKEN: "secret-tg", TELEGRAM_BOT_TOKEN: "secret-tg",
TELEGRAM_ALLOWED_USER_IDS: "111, 222", TELEGRAM_ALLOWED_USER_IDS: "111, 222",
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import { ConversationStore } from "../src/conversations/store.js";
import { ChatService } from "../src/agent/chat-service.js";
import type { OllamaClient, OllamaMessage } from "../src/llm/ollama.js";
import type { Guard } from "../src/agent/types.js";
import { createLogger } from "../src/audit/log.js";
const log = createLogger("silent");
const allowGuard: Guard = { authorize: () => ({ allowed: true }) };
describe("ConversationStore", () => {
it("crée, ajoute, relit dans l'ordre et liste par récence", () => {
const s = new ConversationStore(":memory:");
const a = s.create("api:owner", "Bonjour CHLOVA", 1000);
s.append(a, "user", "Bonjour CHLOVA", 1000);
s.append(a, "assistant", "Salut", 1001);
const b = s.create("api:owner", "Autre sujet", 2000);
s.append(b, "user", "Autre sujet", 2000);
expect(s.ownerOf(a)).toBe("api:owner");
expect(s.messages(a).map((m) => m.role)).toEqual(["user", "assistant"]);
// b plus récent (updated_at) → en tête de liste.
expect(s.list("api:owner").map((c) => c.id)).toEqual([b, a]);
expect(s.list("api:owner")[0]!.title).toBe("Autre sujet");
s.close();
});
it("recent() borne et conserve l'ordre chronologique", () => {
const s = new ConversationStore(":memory:");
const c = s.create("u", "x", 0);
for (let i = 0; i < 30; i++) s.append(c, i % 2 ? "assistant" : "user", `m${i}`, i);
const recent = s.recent(c, 5);
expect(recent).toHaveLength(5);
expect(recent.map((m) => m.content)).toEqual(["m25", "m26", "m27", "m28", "m29"]);
s.close();
});
it("isole les conversations par acteur", () => {
const s = new ConversationStore(":memory:");
s.create("a", "x");
s.create("b", "y");
expect(s.list("a")).toHaveLength(1);
expect(s.list("b")).toHaveLength(1);
s.close();
});
});
describe("ChatService mémoire multi-tour", () => {
// Client factice : snapshot (copie) des messages AU MOMENT de l'appel — le
// tableau réel est muté ensuite par la boucle (push assistant), donc on copie.
let lastLen = 0;
let lastContents: string[] = [];
const client = {
chat: async (req: { messages: OllamaMessage[] }) => {
lastLen = req.messages.length;
lastContents = req.messages.map((m) => m.content);
return { role: "assistant", content: "ok" } as OllamaMessage;
},
} as unknown as OllamaClient;
it("persiste et rejoue l'historique au tour suivant", async () => {
const store = new ConversationStore(":memory:");
const svc = new ChatService({
client,
tools: [],
guard: allowGuard,
systemPrompt: "SYS",
logger: log,
store,
});
const r1 = await svc.handle("api:owner", "premier");
expect(r1.conversationId).toBeTruthy();
// Tour 1 : system + user = 2 messages, pas d'historique.
expect(lastLen).toBe(2);
const r2 = await svc.handle("api:owner", "second", r1.conversationId!);
expect(r2.conversationId).toBe(r1.conversationId);
// Tour 2 : system + (user1, assistant1) + user2 = 4 messages.
expect(lastLen).toBe(4);
expect(lastContents).toEqual(["SYS", "premier", "ok", "second"]);
// 4 messages persistés (2 user + 2 assistant).
expect(store.messages(r1.conversationId!)).toHaveLength(4);
store.close();
});
it("refuse une conversation d'un autre acteur", async () => {
const store = new ConversationStore(":memory:");
const svc = new ChatService({ client, tools: [], guard: allowGuard, systemPrompt: "S", logger: log, store });
const r = await svc.handle("alice", "coucou");
await expect(svc.handle("bob", "intrus", r.conversationId!)).rejects.toThrow();
store.close();
});
});
+6 -5
View File
@@ -14,8 +14,8 @@ function Shell() {
return ( return (
<div className="min-h-dvh flex flex-col"> <div className="min-h-dvh flex flex-col">
<header className="flex items-center gap-2 border-b border-border bg-surface px-4 py-2"> <header className="flex 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-2">CHLOVA</span> <span className="font-bold tracking-wide text-accent glow mr-1 sm:mr-2">CHLOVA</span>
<nav className="flex gap-1"> <nav className="flex gap-1">
<NavLink to="/chat" className={link}> <NavLink to="/chat" className={link}>
<MessageSquare size={16} /> Chat <MessageSquare size={16} /> Chat
@@ -27,10 +27,11 @@ function Shell() {
)} )}
</NavLink> </NavLink>
</nav> </nav>
<span className="ml-auto flex items-center gap-1.5 text-xs text-muted" title="Phase · outils"> <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 || "…"} · {tools} outils <Cpu size={14} /> {phase || "…"}
<span className="hidden sm:inline">· {tools} outils</span>
</span> </span>
<button onClick={logout} className="ml-3 text-muted hover:text-fg cursor-pointer" aria-label="Déconnexion" title="Déconnexion"> <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} /> <LogOut size={18} />
</button> </button>
</header> </header>
+25 -3
View File
@@ -14,6 +14,19 @@ export interface Asset {
docLink: string | null; docLink: string | null;
} }
export interface ConversationMeta {
id: string;
title: string;
createdAt: number;
updatedAt: number;
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
ts: number;
}
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public status: number, public status: number,
@@ -41,12 +54,21 @@ export const api = {
body: JSON.stringify({ user, password, totp }), body: JSON.stringify({ user, password, totp }),
}), }),
chat: (token: string, message: string) => chat: (token: string, message: string, conversationId?: string | null) =>
req<{ reply: string }>("/chat", token, { req<{ reply: string; conversationId: string | null }>("/chat", token, {
method: "POST", method: "POST",
body: JSON.stringify({ message }), body: JSON.stringify({ message, conversationId: conversationId ?? undefined }),
}), }),
conversations: (token: string) =>
req<{ conversations: ConversationMeta[] }>("/conversations", token),
conversation: (token: string, id: string) =>
req<{ messages: ChatMessage[] }>(`/conversations/${encodeURIComponent(id)}`, token),
deleteConversation: (token: string, id: string) =>
req<{ ok: boolean }>(`/conversations/${encodeURIComponent(id)}`, token, { method: "DELETE" }),
review: (token: string) => req<{ assets: Asset[] }>("/review", token), review: (token: string) => req<{ assets: Asset[] }>("/review", token),
approve: (token: string, id: string) => approve: (token: string, id: string) =>
+198 -76
View File
@@ -1,7 +1,7 @@
import { useCallback, 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 { Mic, Square, Volume2, VolumeX, Radio, Send, Plus, Trash2, PanelLeft } from "lucide-react";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { api, ApiError } from "../api"; import { api, ApiError, type ConversationMeta } from "../api";
import { useSpeech } from "../useSpeech"; import { useSpeech } from "../useSpeech";
interface Msg { interface Msg {
@@ -13,6 +13,9 @@ export function Chat() {
const { token, logout } = useAuth(); const { token, logout } = useAuth();
const speech = useSpeech(); const speech = useSpeech();
const [messages, setMessages] = useState<Msg[]>([]); const [messages, setMessages] = useState<Msg[]>([]);
const [convId, setConvId] = useState<string | null>(null);
const [conversations, setConversations] = useState<ConversationMeta[]>([]);
const [showList, setShowList] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -23,6 +26,62 @@ export function Chat() {
bottom.current?.scrollIntoView({ behavior: "smooth" }); bottom.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, busy]); }, [messages, busy]);
const onErr = useCallback(
(err: unknown): void => {
if (err instanceof ApiError && err.status === 401) logout();
else setError(err instanceof Error ? err.message : "Erreur");
},
[logout],
);
const loadConversations = useCallback(async (): Promise<void> => {
if (!token) return;
try {
const { conversations } = await api.conversations(token);
setConversations(conversations);
} catch (err) {
onErr(err);
}
}, [token, onErr]);
useEffect(() => {
void loadConversations();
}, [loadConversations]);
const openConversation = useCallback(
async (id: string): Promise<void> => {
if (!token) return;
setShowList(false);
try {
const { messages } = await api.conversation(token, id);
setMessages(messages.map((m) => ({ role: m.role, text: m.content })));
setConvId(id);
setError(null);
} catch (err) {
onErr(err);
}
},
[token, onErr],
);
const newConversation = (): void => {
setConvId(null);
setMessages([]);
setError(null);
setShowList(false);
};
const deleteConversation = async (id: string): Promise<void> => {
if (!token || !confirm("Supprimer cette conversation ?")) return;
try {
await api.deleteConversation(token, id);
if (id === convId) newConversation();
await loadConversations();
} catch (err) {
onErr(err);
}
};
const toggleSpeak = (): void => { const toggleSpeak = (): void => {
setSpeakReplies((v) => { setSpeakReplies((v) => {
const next = !v; const next = !v;
@@ -36,13 +95,16 @@ export function Chat() {
async (text: string): Promise<void> => { async (text: string): Promise<void> => {
const t = text.trim(); const t = text.trim();
if (!t || busy || !token) return; if (!t || busy || !token) return;
const wasNew = convId === null;
setInput(""); setInput("");
setError(null); setError(null);
setMessages((m) => [...m, { role: "user", text: t }]); setMessages((m) => [...m, { role: "user", text: t }]);
setBusy(true); setBusy(true);
try { try {
const { reply } = await api.chat(token, t); const { reply, conversationId } = await api.chat(token, t, convId);
setMessages((m) => [...m, { role: "assistant", text: reply }]); setMessages((m) => [...m, { role: "assistant", text: reply }]);
if (conversationId) setConvId(conversationId);
if (wasNew) void loadConversations(); // nouvelle conversation → rafraîchir la liste
if (speakReplies || speech.handsFree) speech.speak(reply); if (speakReplies || speech.handsFree) speech.speak(reply);
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) { if (err instanceof ApiError && err.status === 401) {
@@ -54,7 +116,7 @@ export function Chat() {
setBusy(false); setBusy(false);
} }
}, },
[busy, token, speakReplies, speech, logout], [busy, token, convId, speakReplies, speech, logout, loadConversations],
); );
const submit = (e: FormEvent): void => { const submit = (e: FormEvent): void => {
@@ -67,88 +129,148 @@ export function Chat() {
speech.stopListening(); speech.stopListening();
return; return;
} }
speech.listen((text) => void sendText(text)); // dicter → envoyer speech.listen((text) => void sendText(text));
}; };
const toggleHandsFree = (): void => { const toggleHandsFree = (): void => {
if (speech.handsFree) speech.stopHandsFree(); if (speech.handsFree) speech.stopHandsFree();
else speech.startHandsFree((text) => void sendText(text)); // « CHLOVA … » → envoyer else speech.startHandsFree((text) => void sendText(text));
}; };
return ( return (
<div className="flex h-full flex-col"> <div className="relative flex h-full">
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3"> {/* Backdrop mobile quand la liste est ouverte */}
{messages.length === 0 && <p className="text-muted text-sm">Pose une question à CHLOVA</p>} {showList && (
{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.text}
</div>
</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={submit} className="flex items-center gap-2 border-t border-border bg-surface p-3">
{speech.ttsSupported && (
<button
type="button"
onClick={toggleSpeak}
aria-label={speakReplies ? "Couper la voix" : "Activer la voix"}
title={speakReplies ? "Voix activée" : "Voix coupée"}
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
>
{speakReplies ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
)}
<input
className="flex-1 rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent"
placeholder={speech.listening ? "Écoute…" : "Message…"}
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={busy}
/>
{speech.sttSupported && !speech.handsFree && (
<button
type="button"
onClick={mic}
aria-label={speech.listening ? "Arrêter le micro" : "Parler"}
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.listening ? "border-accent text-accent animate-pulse" : "border-border text-muted"}`}
>
{speech.listening ? <Square size={18} /> : <Mic size={18} />}
</button>
)}
{speech.sttSupported && (
<button
type="button"
onClick={toggleHandsFree}
aria-label={speech.handsFree ? "Couper les mains libres" : "Activer les mains libres"}
title="Mains libres (wake-word CHLOVA)"
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
>
<Radio size={18} />
</button>
)}
<button <button
type="submit" aria-label="Fermer la liste"
disabled={busy || !input.trim()} onClick={() => setShowList(false)}
aria-label="Envoyer" className="absolute inset-0 z-10 bg-black/50 md:hidden"
className="flex items-center gap-1.5 rounded-md bg-accent px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer" />
)}
{/* Liste des conversations (drawer mobile, fixe en md+) */}
<aside
className={`${showList ? "flex" : "hidden"} md:flex absolute md:static inset-y-0 left-0 z-20 w-64 shrink-0 flex-col border-r border-border bg-surface`}
>
<button
onClick={newConversation}
className="m-2 flex items-center justify-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-medium text-bg cursor-pointer ring-accent"
> >
<Send size={16} /> Envoyer <Plus size={16} /> Nouvelle
</button> </button>
</form> <ul className="flex-1 overflow-y-auto px-2 pb-2 space-y-1">
{conversations.length === 0 && (
<li className="px-2 py-1 text-xs text-muted">Aucune conversation.</li>
)}
{conversations.map((c) => (
<li key={c.id} className="group flex items-center gap-1">
<button
onClick={() => void openConversation(c.id)}
className={`flex-1 truncate rounded-md px-2 py-1.5 text-left text-sm cursor-pointer ${c.id === convId ? "bg-surface-2 text-accent" : "text-muted hover:text-fg hover:bg-surface-2"}`}
title={c.title}
>
{c.title}
</button>
<button
onClick={() => void deleteConversation(c.id)}
aria-label="Supprimer"
className="shrink-0 p-1 text-muted hover:text-danger cursor-pointer"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
</aside>
{/* Zone de conversation */}
<div className="flex h-full flex-1 flex-col min-w-0">
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 md:hidden">
<button
onClick={() => setShowList((v) => !v)}
aria-label="Conversations"
className="rounded-md border border-border p-1.5 text-muted hover:text-fg cursor-pointer"
>
<PanelLeft size={16} />
</button>
<span className="truncate text-sm text-muted">
{conversations.find((c) => c.id === convId)?.title ?? "Nouvelle conversation"}
</span>
</div>
<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.map((m, i) => (
<div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
<div
className={
"max-w-[85%] sm: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.text}
</div>
</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={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 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()}
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"
>
<Send size={16} /> <span className="hidden sm:inline">Envoyer</span>
</button>
</form>
</div>
</div> </div>
); );
} }
+3 -3
View File
@@ -39,16 +39,16 @@ export function Review() {
expire {new Date(a.expiresAt).toISOString().slice(0, 10)} expire {new Date(a.expiresAt).toISOString().slice(0, 10)}
</span> </span>
)} )}
<div className="ml-auto flex gap-2"> <div className="flex gap-2 w-full sm:w-auto sm:ml-auto">
<button <button
onClick={() => void approve(a.id)} onClick={() => void approve(a.id)}
className="flex items-center gap-1 rounded-md bg-success/15 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent" 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 <Check size={15} /> Approuver
</button> </button>
<button <button
onClick={() => onRefuse(a.id)} onClick={() => onRefuse(a.id)}
className="flex items-center gap-1 rounded-md bg-danger/15 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent" 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 <X size={15} /> Refuser
</button> </button>