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:
@@ -6,6 +6,24 @@ 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)
|
## [0.35.0] — 2026-06-23 — UI responsive (mobile)
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Header** : `flex-wrap` + paddings/marges réduits sur petit écran, « · N outils »
|
- **Header** : `flex-wrap` + paddings/marges réduits sur petit écran, « · N outils »
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 { 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";
|
||||||
|
|
||||||
@@ -110,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
|
||||||
@@ -142,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,
|
||||||
});
|
});
|
||||||
@@ -179,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());
|
||||||
@@ -191,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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-3
@@ -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
@@ -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-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
|
<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 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"
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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} /> <span className="hidden sm:inline">Envoyer</span>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user