paperclip / ui /src /components /AgentMemoryTab.tsx
Leon4gr45's picture
Bundle paperclip + openviking into HF Docker Space
64648ce
Raw
History Blame Contribute Delete
17.4 kB
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
agentMemoriesApi,
type AgentMemory,
type AgentMemoryCreateRequest,
type AgentMemoryUpdateRequest,
} from "../api/agentMemories";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { useToast } from "../context/ToastContext";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Brain, Plus, Pencil, Trash2, Globe, FolderOpen } from "lucide-react";
const CATEGORIES = ["pattern", "preference", "decision", "learning", "feedback"] as const;
const SOURCES = ["self", "ceo", "board", "human"] as const;
const categoryColors: Record<string, string> = {
pattern: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
preference: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
decision: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
learning: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
feedback: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
};
const sourceColors: Record<string, string> = {
self: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300",
ceo: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300",
board: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300",
human: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300",
};
interface AgentMemoryTabProps {
agentId: string;
companyId: string;
}
interface MemoryFormState {
title: string;
content: string;
scope: "global" | "project";
projectId: string | null;
category: AgentMemory["category"];
source: AgentMemory["source"];
confidence: number;
}
const emptyForm: MemoryFormState = {
title: "",
content: "",
scope: "global",
projectId: null,
category: "learning",
source: "human",
confidence: 0.8,
};
export function AgentMemoryTab({ agentId, companyId }: AgentMemoryTabProps) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingMemory, setEditingMemory] = useState<AgentMemory | null>(null);
const [form, setForm] = useState<MemoryFormState>(emptyForm);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const memoriesQuery = useQuery({
queryKey: queryKeys.agentMemories.list(agentId),
queryFn: () => agentMemoriesApi.list(agentId),
});
const projectsQuery = useQuery({
queryKey: queryKeys.projects.list(companyId),
queryFn: () => projectsApi.list(companyId),
});
const createMutation = useMutation({
mutationFn: (data: AgentMemoryCreateRequest) => agentMemoriesApi.create(agentId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agentMemories.list(agentId) });
pushToast({ title: "Memory created" });
closeDialog();
},
onError: () => pushToast({ tone: "warn", title: "Failed to create memory" }),
});
const updateMutation = useMutation({
mutationFn: ({ memoryId, data }: { memoryId: string; data: AgentMemoryUpdateRequest }) =>
agentMemoriesApi.update(agentId, memoryId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agentMemories.list(agentId) });
pushToast({ title: "Memory updated" });
closeDialog();
},
onError: () => pushToast({ tone: "warn", title: "Failed to update memory" }),
});
const deleteMutation = useMutation({
mutationFn: (memoryId: string) => agentMemoriesApi.delete(agentId, memoryId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agentMemories.list(agentId) });
pushToast({ title: "Memory deleted" });
setDeleteConfirm(null);
},
onError: () => pushToast({ tone: "warn", title: "Failed to delete memory" }),
});
const memories = memoriesQuery.data ?? [];
const globalMemories = memories.filter((m) => m.scope === "global");
const projectMemories = memories.filter(
(m) => m.scope === "project" && (!selectedProjectId || m.projectId === selectedProjectId),
);
const projects = projectsQuery.data ?? [];
function openCreate() {
setEditingMemory(null);
setForm(emptyForm);
setDialogOpen(true);
}
function openEdit(memory: AgentMemory) {
setEditingMemory(memory);
setForm({
title: memory.title,
content: memory.content,
scope: memory.scope,
projectId: memory.projectId,
category: memory.category,
source: memory.source,
confidence: memory.confidence,
});
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingMemory(null);
setForm(emptyForm);
}
function handleSave() {
const payload = {
title: form.title,
content: form.content,
scope: form.scope,
projectId: form.scope === "project" ? form.projectId : null,
category: form.category,
source: form.source,
confidence: form.confidence,
};
if (editingMemory) {
updateMutation.mutate({ memoryId: editingMemory.id, data: payload });
} else {
createMutation.mutate(payload);
}
}
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="space-y-8">
{/* Global Memory */}
<section>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Global Memory</h3>
<span className="text-xs text-muted-foreground">({globalMemories.length})</span>
</div>
<Button size="sm" variant="outline" onClick={openCreate}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Memory
</Button>
</div>
{globalMemories.length === 0 ? (
<EmptyMemoryState />
) : (
<div className="space-y-2">
{globalMemories.map((memory) => (
<MemoryCard
key={memory.id}
memory={memory}
onEdit={() => openEdit(memory)}
onDelete={() => setDeleteConfirm(memory.id)}
/>
))}
</div>
)}
</section>
{/* Project Memory */}
<section>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Project Memory</h3>
<span className="text-xs text-muted-foreground">({projectMemories.length})</span>
</div>
<div className="flex items-center gap-2">
<Select
value={selectedProjectId ?? "__all__"}
onValueChange={(v) => setSelectedProjectId(v === "__all__" ? null : v)}
>
<SelectTrigger className="h-8 w-48 text-xs">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={openCreate}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add
</Button>
</div>
</div>
{projectMemories.length === 0 ? (
<EmptyMemoryState />
) : (
<div className="space-y-2">
{projectMemories.map((memory) => (
<MemoryCard
key={memory.id}
memory={memory}
onEdit={() => openEdit(memory)}
onDelete={() => setDeleteConfirm(memory.id)}
/>
))}
</div>
)}
</section>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingMemory ? "Edit Memory" : "Add Memory"}</DialogTitle>
<DialogDescription>
{editingMemory
? "Update this memory entry."
: "Add a new memory to help this agent learn and improve."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Title</label>
<Input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="Memory title"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Content</label>
<Textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="Describe the memory..."
rows={4}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Scope</label>
<Select
value={form.scope}
onValueChange={(v) => setForm({ ...form, scope: v as "global" | "project" })}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="project">Project</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Category</label>
<Select
value={form.category}
onValueChange={(v) => setForm({ ...form, category: v as AgentMemory["category"] })}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{form.scope === "project" && (
<div>
<label className="text-xs font-medium text-muted-foreground">Project</label>
<Select
value={form.projectId ?? ""}
onValueChange={(v) => setForm({ ...form, projectId: v || null })}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Source</label>
<Select
value={form.source}
onValueChange={(v) => setForm({ ...form, source: v as AgentMemory["source"] })}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOURCES.map((s) => (
<SelectItem key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Confidence ({Math.round(form.confidence * 100)}%)
</label>
<input
type="range"
min={0}
max={1}
step={0.05}
value={form.confidence}
onChange={(e) => setForm({ ...form, confidence: parseFloat(e.target.value) })}
className="w-full mt-2"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!form.title.trim() || !form.content.trim() || isSaving}
>
{isSaving ? "Saving..." : editingMemory ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Memory</DialogTitle>
<DialogDescription>
Are you sure you want to delete this memory? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function MemoryCard({
memory,
onEdit,
onDelete,
}: {
memory: AgentMemory;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<Card className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium truncate">{memory.title}</h4>
<Badge
variant="secondary"
className={cn("text-[10px] px-1.5 py-0", categoryColors[memory.category])}
>
{memory.category}
</Badge>
<Badge
variant="secondary"
className={cn("text-[10px] px-1.5 py-0", sourceColors[memory.source])}
>
{memory.source}
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">{memory.content}</p>
<div className="flex items-center gap-3 mt-2">
<div className="flex items-center gap-1">
<div className="h-1.5 w-16 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${memory.confidence * 100}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground">
{Math.round(memory.confidence * 100)}%
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 p-0 text-destructive" onClick={onDelete}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</Card>
);
}
function EmptyMemoryState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Brain className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">
No memories yet. Add memories to help this agent learn and improve.
</p>
</div>
);
}