spb/chat-spboucher-ai
Public
1"use client";2import { useMemo, useState } from "react";3import useSWR from "swr";4import { Brain, Plus, Search, Download, ShieldCheck } from "lucide-react";5import { fetcher } from "@/lib/client/fetcher";6import type { MemoryDTO } from "@/lib/client/types";7import { Button, Empty, Spinner, cn } from "@/components/ui";8import { MemoryCard } from "@/components/memory/memory-card";9import { AddMemoryDialog } from "@/components/memory/add-memory-dialog";10import { CATEGORIES, MemoryToggles, categoryLabel } from "@/components/memory/memory-shared";1112interface Resp { memories: MemoryDTO[]; stats: { status: string; n: number }[] }1314export default function MemoryPage() {15 const [q, setQ] = useState(""); const [cat, setCat] = useState<string | null>(null); const [add, setAdd] = useState(false);16 const { data, isLoading, mutate } = useSWR<Resp>(`/api/memories${q ? `?q=${encodeURIComponent(q)}` : ""}`, fetcher, { keepPreviousData: true });17 const stat = (s: string) => data?.stats.find(x => x.status === s)?.n ?? 0;18 const candidates = useMemo(() => (data?.memories ?? []).filter(m => m.status === "candidate"), [data]);19 const grouped = useMemo(() => {20 const list = (data?.memories ?? []).filter(m => m.status !== "candidate" && (!cat || m.category === cat));21 const g = new Map<string, MemoryDTO[]>();22 for (const c of CATEGORIES) g.set(c.id, []);23 for (const m of list) { if (!g.has(m.category)) g.set(m.category, []); g.get(m.category)!.push(m); }24 return [...g.entries()].filter(([, v]) => v.length);25 }, [data, cat]);26 const refresh = () => void mutate();2728 return (29 <div className="min-h-0 flex-1 overflow-y-auto">30 <div className="mx-auto w-full max-w-4xl px-4 py-6 md:px-8">31 <div className="mb-5 flex flex-wrap items-start justify-between gap-3">32 <div>33 <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight"><Brain className="h-5 w-5 text-accent-2" /> Mémoire</h1>34 <p className="mt-1 text-[13px] text-muted">Faits durables réutilisés dans tes conversations. Tu vois tout, tu contrôles tout : rien n'est stocké en silence.</p>35 </div>36 <div className="flex gap-2">37 <a href="/api/memories/export?format=md" className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border px-3 text-sm text-fg-2 hover:bg-surface-2 hover:text-fg"><Download className="h-4 w-4" /> Markdown</a>38 <a href="/api/memories/export?format=json" className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border px-3 text-sm text-fg-2 hover:bg-surface-2 hover:text-fg"><Download className="h-4 w-4" /> JSON</a>39 <Button variant="primary" onClick={() => setAdd(true)}><Plus className="h-4 w-4" /> Ajouter</Button>40 </div>41 </div>4243 <div className="mb-4 grid grid-cols-3 gap-2">44 {[{ l: "Actifs", v: stat("active"), tone: "text-ok" }, { l: "Candidats", v: stat("candidate"), tone: "text-warn" }, { l: "Désactivés", v: stat("disabled"), tone: "text-muted" }].map(t => (45 <div key={t.l} className="rounded-xl border border-border bg-surface px-4 py-3"><div className={cn("text-2xl font-semibold tabular-nums", t.tone)}>{t.v}</div><div className="text-[12px] text-muted">{t.l}</div></div>46 ))}47 </div>48 <div className="mb-6"><MemoryToggles /></div>4950 {candidates.length > 0 && (51 <section className="mb-6">52 <h2 className="mb-2 flex items-center gap-2 text-[13px] font-semibold uppercase tracking-wider text-warn"><ShieldCheck className="h-4 w-4" /> Candidats à approuver · {candidates.length}</h2>53 <div className="space-y-2">{candidates.map(m => <MemoryCard key={m.id} m={m} candidate onChanged={refresh} />)}</div>54 </section>55 )}5657 <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center">58 <div className="relative flex-1"><Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted" /><input value={q} onChange={e => setQ(e.target.value)} placeholder="Rechercher dans la mémoire…" className="input !pl-8" /></div>59 {isLoading && <Spinner />}60 </div>61 <div className="mb-4 flex flex-wrap gap-1.5">62 <button onClick={() => setCat(null)} className={cn("rounded-lg border px-2.5 py-1 text-[12px]", !cat ? "border-accent bg-accent/10 text-accent" : "border-border text-fg-2 hover:bg-surface-2")}>Toutes</button>63 {CATEGORIES.map(c => <button key={c.id} onClick={() => setCat(cat === c.id ? null : c.id)} className={cn("rounded-lg border px-2.5 py-1 text-[12px]", cat === c.id ? "border-accent bg-accent/10 text-accent" : "border-border text-fg-2 hover:bg-surface-2")}>{c.label}</button>)}64 </div>6566 {data && !grouped.length && <Empty icon={<Brain className="h-8 w-8" />} title={q ? "Aucun souvenir ne correspond" : "Aucun souvenir pour l'instant"} hint="Les souvenirs apparaissent au fil des conversations (extraction automatique) ou quand tu en ajoutes un." action={<Button onClick={() => setAdd(true)}><Plus className="h-4 w-4" /> Ajouter un souvenir</Button>} />}67 {grouped.map(([c, items]) => (68 <section key={c} className="mb-6">69 <h2 className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-muted">{categoryLabel(c)} · {items.length}</h2>70 <div className="space-y-2">{items.map(m => <MemoryCard key={m.id} m={m} onChanged={refresh} />)}</div>71 </section>72 ))}73 </div>74 <AddMemoryDialog open={add} onClose={() => setAdd(false)} onAdded={refresh} />75 </div>76 );77}78