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:
Kantin-Petit
2026-06-23 23:25:42 +02:00
parent 4f3c85901e
commit 0da5e2aba1
11 changed files with 660 additions and 89 deletions
+25 -3
View File
@@ -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) =>
+198 -76
View File
@@ -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<Msg[]>([]);
const [convId, setConvId] = useState<string | null>(null);
const [conversations, setConversations] = useState<ConversationMeta[]>([]);
const [showList, setShowList] = useState(false);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<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 => {
setSpeakReplies((v) => {
const next = !v;
@@ -36,13 +95,16 @@ export function Chat() {
async (text: string): Promise<void> => {
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 (
<div className="flex h-full flex-col">
<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-[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>
)}
<div className="relative flex h-full">
{/* Backdrop mobile quand la liste est ouverte */}
{showList && (
<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"
aria-label="Fermer la liste"
onClick={() => setShowList(false)}
className="absolute inset-0 z-10 bg-black/50 md:hidden"
/>
)}
{/* 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>
</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>
);
}