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,
|
||||
|
||||
Reference in New Issue
Block a user