409 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|