Files
adventure/apps/web/src/components/settings/ai-memory-panel.tsx
Zaine 1e0a4c97b3
Some checks failed
CI / test (push) Has been cancelled
fix the learning
2026-06-26 16:09:04 +01:00

409 lines
14 KiB
TypeScript

"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
type Memory = {
id: string;
category: string;
title: string;
content: string;
sourceType: string;
enabled: boolean;
userVerified: boolean;
};
type Suggestion = {
id: string;
category: string;
title: string;
content: string;
confidence: string;
sourceType: string;
};
export function AiMemoryPanel() {
const qc = useQueryClient();
const [tab, setTab] = useState<"memories" | "suggestions" | "summary" | "learning">("memories");
const [filter, setFilter] = useState("");
const [category, setCategory] = useState("");
const [newMem, setNewMem] = useState({ category: "likes" as MemoryCategory, title: "", content: "" });
const { data } = useQuery({
queryKey: ["ai-memory", filter, category],
queryFn: async () => {
const params = new URLSearchParams();
if (filter) params.set("q", filter);
if (category) params.set("category", category);
const res = await fetch(`/api/ai/memory?${params}`);
return res.json();
},
});
const { data: learningData } = useQuery({
queryKey: ["ai-memory-learning"],
queryFn: async () => {
const res = await fetch("/api/ai/memory/learning");
return res.json();
},
});
const { data: suggestionsData } = useQuery({
queryKey: ["ai-memory-suggestions"],
queryFn: async () => {
const res = await fetch("/api/ai/memory/suggestions");
return res.json();
},
});
const createMem = useMutation({
mutationFn: async () => {
const res = await fetch("/api/ai/memory", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newMem),
});
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-memory"] });
setNewMem({ category: "likes", title: "", content: "" });
},
});
const saveSummary = useMutation({
mutationFn: async (summary: string) => {
const res = await fetch("/api/ai/memory/summary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ summary }),
});
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-memory"] }),
});
const saveLearning = useMutation({
mutationFn: async (body: Record<string, unknown>) => {
const res = await fetch("/api/ai/memory/learning", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-memory-learning"] }),
});
const memories: Memory[] = data?.memories ?? [];
const summary = data?.summary?.summary ?? "";
const suggestions: Suggestion[] = suggestionsData?.suggestions ?? [];
const learning = learningData ?? {};
return (
<div className="space-y-4">
<p className="text-sm text-[var(--warm-grey)]">
What the AI knows about you fully editable. Raw logs and reflections stay separate.
</p>
<div className="flex flex-wrap gap-1">
{(["memories", "suggestions", "summary", "learning"] as const).map((t) => (
<button
key={t}
type="button"
className={`retro-btn text-xs ${tab === t ? "retro-btn-primary" : ""}`}
onClick={() => setTab(t)}
>
{t === "suggestions" ? `Suggestions (${suggestions.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "memories" && (
<>
<div className="flex gap-2 flex-wrap">
<input
className="retro-window-inset p-1 text-sm flex-1 min-w-[120px]"
placeholder="Search memories..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select
className="retro-window-inset text-sm p-1"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">All categories</option>
{Object.entries(MEMORY_CATEGORIES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div className="retro-window p-3 space-y-2">
<p className="text-xs font-bold">Add memory manually</p>
<select
className="retro-window-inset text-sm p-1 w-full"
value={newMem.category}
onChange={(e) => setNewMem({ ...newMem, category: e.target.value as MemoryCategory })}
>
{Object.entries(MEMORY_CATEGORIES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<input
className="retro-window-inset p-1 text-sm w-full"
placeholder="Title"
value={newMem.title}
onChange={(e) => setNewMem({ ...newMem, title: e.target.value })}
/>
<textarea
className="retro-window-inset p-1 text-sm w-full"
placeholder="What should the AI remember?"
rows={2}
value={newMem.content}
onChange={(e) => setNewMem({ ...newMem, content: e.target.value })}
/>
<button
type="button"
className="retro-btn retro-btn-primary text-xs"
disabled={!newMem.title.trim() || !newMem.content.trim()}
onClick={() => createMem.mutate()}
>
Add memory
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{memories.map((m) => (
<MemoryRow key={m.id} memory={m} onChange={() => qc.invalidateQueries({ queryKey: ["ai-memory"] })} />
))}
{memories.length === 0 && (
<p className="text-sm italic text-[var(--warm-grey)]">No memories yet. Add one above.</p>
)}
</div>
<div className="flex gap-2">
<a href="/api/ai/memory/summary" className="retro-btn text-xs" download>
Export memories
</a>
<button
type="button"
className="retro-btn text-xs text-[var(--muted-rose)]"
onClick={async () => {
if (!confirm("Archive all memories? This cannot be undone easily.")) return;
await fetch("/api/ai/memory/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
qc.invalidateQueries({ queryKey: ["ai-memory"] });
}}
>
Reset all memories
</button>
</div>
</>
)}
{tab === "suggestions" && (
<div className="space-y-2">
{suggestions.length === 0 && (
<p className="text-sm italic">No pending suggestions. Enable learning to receive them.</p>
)}
{suggestions.map((s) => (
<SuggestionRow key={s.id} suggestion={s} onChange={() => {
qc.invalidateQueries({ queryKey: ["ai-memory-suggestions"] });
qc.invalidateQueries({ queryKey: ["ai-memory"] });
}} />
))}
</div>
)}
{tab === "summary" && (
<div className="space-y-2">
<p className="text-xs text-[var(--warm-grey)]">
AI-generated profile summary (editable). Used as compact context for the mentor.
</p>
<textarea
className="retro-window-inset p-2 text-sm w-full"
rows={6}
value={summary}
onChange={(e) => saveSummary.mutate(e.target.value)}
/>
<button
type="button"
className="retro-btn text-xs"
onClick={async () => {
const res = await fetch("/api/ai/memory/summary/rebuild", { method: "POST" });
if (res.ok) qc.invalidateQueries({ queryKey: ["ai-memory"] });
}}
>
Rebuild from memories
</button>
</div>
)}
{tab === "learning" && (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.learningEnabled ?? false}
onChange={(e) => saveLearning.mutate({ learningEnabled: e.target.checked })}
/>
Enable memory learning (opt-in)
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.requireApproval ?? true}
onChange={(e) => saveLearning.mutate({ requireApproval: e.target.checked })}
/>
Require approval before saving
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.suggestAfterReflection ?? true}
onChange={(e) => saveLearning.mutate({ suggestAfterReflection: e.target.checked })}
/>
Suggest after reflections
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={learning.allowSensitiveCategories ?? false}
onChange={(e) => saveLearning.mutate({ allowSensitiveCategories: e.target.checked })}
/>
Allow learning sensitive categories
</label>
</div>
)}
</div>
);
}
function MemoryRow({ memory, onChange }: { memory: Memory; onChange: () => void }) {
const toggle = async () => {
await fetch(`/api/ai/memory/${memory.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !memory.enabled }),
});
onChange();
};
const archive = async () => {
await fetch(`/api/ai/memory/${memory.id}`, { method: "DELETE" });
onChange();
};
return (
<div className={`retro-window p-2 text-sm ${!memory.enabled ? "opacity-50" : ""}`}>
<div className="flex justify-between gap-2">
<span className="font-bold">{memory.title}</span>
<span className="text-xs text-[var(--warm-grey)]">
{MEMORY_CATEGORIES[memory.category as MemoryCategory] ?? memory.category}
</span>
</div>
<p className="text-xs mt-1">{memory.content}</p>
<p className="text-[10px] text-[var(--warm-grey)] mt-1">Source: {memory.sourceType}</p>
<div className="flex gap-2 mt-2">
<button type="button" className="retro-btn text-xs" onClick={toggle}>
{memory.enabled ? "Disable" : "Enable"}
</button>
<button type="button" className="retro-btn text-xs" onClick={archive}>Archive</button>
</div>
</div>
);
}
function SuggestionRow({ suggestion, onChange }: { suggestion: Suggestion; onChange: () => void }) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(suggestion.title);
const [content, setContent] = useState(suggestion.content);
const [category, setCategory] = useState(suggestion.category);
return (
<div className="retro-window p-2 text-sm border-l-4 border-[var(--xp-blue)]">
{editing ? (
<div className="space-y-2">
<input
className="retro-window-inset w-full p-1 text-xs"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<select
className="retro-window-inset w-full p-1 text-xs"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{Object.entries(MEMORY_CATEGORIES).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<textarea
className="retro-window-inset w-full p-1 text-xs min-h-20"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</div>
) : (
<>
<p className="font-bold">{suggestion.title}</p>
<p className="text-xs mt-1">{suggestion.content}</p>
<p className="text-[10px] text-[var(--warm-grey)]">
{MEMORY_CATEGORIES[suggestion.category as MemoryCategory]} · confidence {suggestion.confidence}
</p>
</>
)}
<div className="flex gap-2 mt-2">
<button
type="button"
className="retro-btn retro-btn-primary text-xs"
onClick={async () => {
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editing ? { title, content, category } : {}),
});
onChange();
}}
disabled={!title.trim() || !content.trim()}
>
Accept
</button>
<button
type="button"
className="retro-btn text-xs"
onClick={() => setEditing((value) => !value)}
>
{editing ? "Cancel edit" : "Edit"}
</button>
<button
type="button"
className="retro-btn text-xs"
onClick={async () => {
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/reject`, { method: "POST" });
onChange();
}}
>
Reject
</button>
<button
type="button"
className="retro-btn text-xs"
onClick={async () => {
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/ignore`, { method: "POST" });
onChange();
}}
>
Ignore
</button>
</div>
</div>
);
}