365
apps/web/src/components/settings/ai-memory-panel.tsx
Normal file
365
apps/web/src/components/settings/ai-memory-panel.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"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 }) {
|
||||
return (
|
||||
<div className="retro-window p-2 text-sm border-l-4 border-[var(--xp-blue)]">
|
||||
<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" });
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user