From 0da5e2aba13bd12c197c68d52fe5e99db8ac3220 Mon Sep 17 00:00:00 2001 From: Kantin-Petit Date: Tue, 23 Jun 2026 23:25:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20conversations=20persistantes=20+?= =?UTF-8?q?=20m=C3=A9moire=20multi-tour=20(v0.36.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi --- CHANGELOG.md | 18 ++ docs/conversations.md | 54 +++++ orchestrator/src/agent/chat-service.ts | 51 ++++- orchestrator/src/agent/loop.ts | 3 + orchestrator/src/api/routes.ts | 48 ++++- orchestrator/src/conversations/store.ts | 162 ++++++++++++++ orchestrator/src/index.ts | 12 +- orchestrator/test/api.test.ts | 4 +- orchestrator/test/conversations.test.ts | 95 ++++++++ web/src/api.ts | 28 ++- web/src/pages/Chat.tsx | 274 +++++++++++++++++------- 11 files changed, 660 insertions(+), 89 deletions(-) create mode 100644 docs/conversations.md create mode 100644 orchestrator/src/conversations/store.ts create mode 100644 orchestrator/test/conversations.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d473a..b593019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [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 » diff --git a/docs/conversations.md b/docs/conversations.md new file mode 100644 index 0000000..66c9929 --- /dev/null +++ b/docs/conversations.md @@ -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:`). 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:` → + 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. diff --git a/orchestrator/src/agent/chat-service.ts b/orchestrator/src/agent/chat-service.ts index 5c85e8e..ba579d3 100644 --- a/orchestrator/src/agent/chat-service.ts +++ b/orchestrator/src/agent/chat-service.ts @@ -1,12 +1,17 @@ import type { Logger } from "pino"; import { OllamaClient } from "../llm/ollama.js"; +import type { OllamaMessage } from "../llm/ollama.js"; import { runAgentTurn } from "./loop.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 * 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 { client: OllamaClient; @@ -14,16 +19,58 @@ export interface ChatServiceDeps { guard: Guard; systemPrompt: string; 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 { constructor(private readonly deps: ChatServiceDeps) {} - async handle(actor: string, text: string): Promise { + async handle(actor: string, text: string, conversationId?: string): Promise { + 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 { const { reply, steps } = await runAgentTurn({ client: this.deps.client, system: this.deps.systemPrompt, userText: text, + history, tools: this.deps.tools, actor, guard: this.deps.guard, diff --git a/orchestrator/src/agent/loop.ts b/orchestrator/src/agent/loop.ts index 762b5a3..3b6a3f7 100644 --- a/orchestrator/src/agent/loop.ts +++ b/orchestrator/src/agent/loop.ts @@ -15,6 +15,8 @@ export interface AgentTurnInput { client: OllamaClient; system: string; userText: string; + /** Historique récent (tours précédents) rejoué avant le nouveau message. */ + history?: OllamaMessage[]; tools: ToolHandle[]; /** Identité de l'appelant (ex. id utilisateur Telegram). */ actor: string; @@ -50,6 +52,7 @@ export async function runAgentTurn( const messages: OllamaMessage[] = [ { role: "system", content: input.system }, + ...(input.history ?? []), { role: "user", content: input.userText }, ]; diff --git a/orchestrator/src/api/routes.ts b/orchestrator/src/api/routes.ts index 73a8840..02dc92b 100644 --- a/orchestrator/src/api/routes.ts +++ b/orchestrator/src/api/routes.ts @@ -1,10 +1,14 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import cors from "@fastify/cors"; 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 { ConversationStore } from "../conversations/store.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. * Réutilise ChatService (même comportement que Telegram) et ReviewService. @@ -16,6 +20,7 @@ export interface ApiDeps { auth: AuthConfig; chat: ChatService; review: ReviewService | null; + conversations: ConversationStore | null; state: () => Record; webOrigin?: string | undefined; } @@ -23,7 +28,7 @@ export interface ApiDeps { export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise { await app.register(cors, { origin: deps.webOrigin ?? false, - methods: ["GET", "POST"], + methods: ["GET", "POST", "DELETE"], allowedHeaders: ["content-type", "authorization"], }); 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; const message = String(body.message ?? "").trim(); if (!message) return reply.code(400).send({ error: "message vide" }); - const replyText = await deps.chat.handle("api:owner", message); - return { reply: replyText }; + const conversationId = + 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 ──────────────────────────────────────────────────────────────── diff --git a/orchestrator/src/conversations/store.ts b/orchestrator/src/conversations/store.ts new file mode 100644 index 0000000..1e5e6f9 --- /dev/null +++ b/orchestrator/src/conversations/store.ts @@ -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(); + } +} diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 305879e..28447f2 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -20,6 +20,7 @@ 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 { ConversationStore } from "./conversations/store.js"; import { buildSystemPrompt } from "./agent/system-prompt.js"; import { TelegramSurface } from "./surfaces/telegram.js"; @@ -110,7 +111,9 @@ async function main(): Promise { apiKey: cfg.ollamaApiKey, 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 ─────────────────────── // Démarrée seulement si un token est fourni. Sinon, surface API/UI seule @@ -142,6 +145,7 @@ async function main(): Promise { auth, chat, review, + conversations, state: stateOf, webOrigin: cfg.webOrigin, }); @@ -179,6 +183,7 @@ async function main(): Promise { await registry.close(); await app.close(); repo?.close(); + conversations.close(); process.exit(0); }; process.on("SIGTERM", () => void shutdown()); @@ -191,7 +196,10 @@ async function main(): Promise { if (review && isCommand(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; }); } } diff --git a/orchestrator/test/api.test.ts b/orchestrator/test/api.test.ts index a3f1f8c..b955751 100644 --- a/orchestrator/test/api.test.ts +++ b/orchestrator/test/api.test.ts @@ -19,7 +19,7 @@ const auth: AuthConfig = { // ChatService stub : pas de vrai LLM. 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; let app: FastifyInstance; @@ -29,7 +29,7 @@ beforeEach(async () => { repo = new AssetRepository(":memory:"); const review = new ReviewService(repo, log); 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(); }); afterEach(async () => { diff --git a/orchestrator/test/conversations.test.ts b/orchestrator/test/conversations.test.ts new file mode 100644 index 0000000..c422e11 --- /dev/null +++ b/orchestrator/test/conversations.test.ts @@ -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(); + }); +}); diff --git a/web/src/api.ts b/web/src/api.ts index 27f4d36..cc8df12 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -14,6 +14,19 @@ export interface Asset { 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 { constructor( public status: number, @@ -41,12 +54,21 @@ export const api = { body: JSON.stringify({ user, password, totp }), }), - chat: (token: string, message: string) => - req<{ reply: string }>("/chat", token, { + chat: (token: string, message: string, conversationId?: string | null) => + req<{ reply: string; conversationId: string | null }>("/chat", token, { 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), approve: (token: string, id: string) => diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 3cf7845..0a1a39b 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -1,7 +1,7 @@ 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 { api, ApiError } from "../api"; +import { api, ApiError, type ConversationMeta } from "../api"; import { useSpeech } from "../useSpeech"; interface Msg { @@ -13,6 +13,9 @@ export function Chat() { const { token, logout } = useAuth(); const speech = useSpeech(); const [messages, setMessages] = useState([]); + const [convId, setConvId] = useState(null); + const [conversations, setConversations] = useState([]); + const [showList, setShowList] = useState(false); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -23,6 +26,62 @@ export function Chat() { bottom.current?.scrollIntoView({ behavior: "smooth" }); }, [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 => { + 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 => { + 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 => { + 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 => { setSpeakReplies((v) => { const next = !v; @@ -36,13 +95,16 @@ export function Chat() { async (text: string): Promise => { const t = text.trim(); if (!t || busy || !token) return; + const wasNew = convId === null; setInput(""); setError(null); setMessages((m) => [...m, { role: "user", text: t }]); setBusy(true); try { - const { reply } = await api.chat(token, t); + const { reply, conversationId } = await api.chat(token, t, convId); 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); } catch (err) { if (err instanceof ApiError && err.status === 401) { @@ -54,7 +116,7 @@ export function Chat() { setBusy(false); } }, - [busy, token, speakReplies, speech, logout], + [busy, token, convId, speakReplies, speech, logout, loadConversations], ); const submit = (e: FormEvent): void => { @@ -67,88 +129,148 @@ export function Chat() { speech.stopListening(); return; } - speech.listen((text) => void sendText(text)); // dicter → envoyer + speech.listen((text) => void sendText(text)); }; const toggleHandsFree = (): void => { if (speech.handsFree) speech.stopHandsFree(); - else speech.startHandsFree((text) => void sendText(text)); // « CHLOVA … » → envoyer + else speech.startHandsFree((text) => void sendText(text)); }; return ( -
-
- {messages.length === 0 &&

Pose une question à CHLOVA…

} - {messages.map((m, i) => ( -
-
- {m.text} -
-
- ))} - {busy &&

CHLOVA réfléchit…

} - {speech.handsFree && !busy && !speech.speaking && ( -

Mains libres — dis « CHLOVA … »

- )} - {speech.speaking &&

Lecture vocale…

} - {error &&

{error}

} -
-
- -
- {speech.ttsSupported && ( - - )} - setInput(e.target.value)} - disabled={busy} - /> - {speech.sttSupported && !speech.handsFree && ( - - )} - {speech.sttSupported && ( - - )} +
+ {/* Backdrop mobile quand la liste est ouverte */} + {showList && ( - +
    + {conversations.length === 0 && ( +
  • Aucune conversation.
  • + )} + {conversations.map((c) => ( +
  • + + +
  • + ))} +
+ + + {/* Zone de conversation */} +
+
+ + + {conversations.find((c) => c.id === convId)?.title ?? "Nouvelle conversation"} + +
+ +
+ {messages.length === 0 &&

Pose une question à CHLOVA…

} + {messages.map((m, i) => ( +
+
+ {m.text} +
+
+ ))} + {busy &&

CHLOVA réfléchit…

} + {speech.handsFree && !busy && !speech.speaking && ( +

Mains libres — dis « CHLOVA … »

+ )} + {speech.speaking &&

Lecture vocale…

} + {error &&

{error}

} +
+
+ +
+ {speech.ttsSupported && ( + + )} + setInput(e.target.value)} + disabled={busy} + /> + {speech.sttSupported && !speech.handsFree && ( + + )} + {speech.sttSupported && ( + + )} + +
+
); }