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:
+25
-3
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user