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
This commit is contained in:
@@ -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<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({
|
||||
client: this.deps.client,
|
||||
system: this.deps.systemPrompt,
|
||||
userText: text,
|
||||
history,
|
||||
tools: this.deps.tools,
|
||||
actor,
|
||||
guard: this.deps.guard,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
webOrigin?: string | undefined;
|
||||
}
|
||||
@@ -23,7 +28,7 @@ export interface ApiDeps {
|
||||
export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise<void> {
|
||||
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<string, unknown>;
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
auth,
|
||||
chat,
|
||||
review,
|
||||
conversations,
|
||||
state: stateOf,
|
||||
webOrigin: cfg.webOrigin,
|
||||
});
|
||||
@@ -179,6 +183,7 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user