Files
kandev/apps/web/app/office/agents/[id]/components/agent-memory-tab.tsx
T
2026-09-25 18:07:55 +01:00

379 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
IconSearch,
IconTrash,
IconDownload,
IconChevronDown,
IconChevronRight,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Badge } from "@kandev/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { toast } from "@/lib/toast/sonner";
import type { AgentProfile } from "@/lib/state/slices/office/types";
import * as officeApi from "@/lib/api/domains/office-api";
import { triggerBlobDownload } from "@/lib/utils/file-download";
import { useTranslation } from "react-i18next";
type MemoryEntry = {
id: string;
layer: string;
key: string;
content: string;
metadata: string;
updated_at: string;
};
type AgentMemoryTabProps = {
agent: AgentProfile;
};
function useMemoryActions(agent: AgentProfile) {
const { t } = useTranslation();
const [entries, setEntries] = useState<MemoryEntry[]>([]);
useEffect(() => {
let cancelled = false;
officeApi
.getMemory(agent.id)
.then((res) => {
if (!cancelled) {
setEntries((res as { memory?: MemoryEntry[] }).memory ?? []);
}
})
.catch((err) => {
toast.error(err instanceof Error ? err.message : t("office:failedToLoadMemory"));
});
return () => {
cancelled = true;
};
}, [agent.id]);
const handleDelete = useCallback(
async (entryId: string) => {
try {
await officeApi.deleteMemory(agent.id, entryId);
setEntries((prev) => prev.filter((e) => e.id !== entryId));
toast.success(t("office:memoryEntryDeleted"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("office:failedToDeleteMemoryEntry"));
}
},
[agent.id],
);
const handleClearAll = useCallback(async () => {
try {
await officeApi.deleteAllMemory(agent.id);
setEntries([]);
toast.success(t("office:allMemoryCleared"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("office:failedToClearMemory"));
}
}, [agent.id]);
const handleExport = useCallback(async () => {
const res = await officeApi.exportMemory(agent.id);
const data = (res as { memory?: MemoryEntry[] }).memory ?? [];
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
triggerBlobDownload(blob, `${agent.name}-memory.json`);
}, [agent.id, agent.name]);
return { entries, handleDelete, handleClearAll, handleExport };
}
function MemoryGroupsList({
grouped,
collapsedLayers,
expandedId,
onToggleLayer,
onToggleEntry,
onDelete,
}: {
grouped: Record<string, MemoryEntry[]>;
collapsedLayers: Set<string>;
expandedId: string | null;
onToggleLayer: (layer: string) => void;
onToggleEntry: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="space-y-4">
{(["operating", "knowledge", "session"] as const).map((layer) => (
<MemoryLayerGroup
key={layer}
layer={layer}
entries={grouped[layer] ?? []}
collapsed={collapsedLayers.has(layer)}
expandedId={expandedId}
onToggleLayer={() => onToggleLayer(layer)}
onToggleEntry={onToggleEntry}
onDelete={onDelete}
/>
))}
</div>
);
}
function EmptyMemoryState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">{t("office:noMemoryEntriesYet")}</p>
<p className="text-xs text-muted-foreground mt-1">
{t("office:memoryIsAccumulatedAsTheAgent")}
</p>
</div>
);
}
export function AgentMemoryTab({ agent }: AgentMemoryTabProps) {
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const [collapsedLayers, setCollapsedLayers] = useState<Set<string>>(new Set());
const { entries, handleDelete, handleClearAll, handleExport } = useMemoryActions(agent);
const filtered = useMemo(() => {
if (!search) return entries;
const q = search.toLowerCase();
return entries.filter(
(e) => e.key.toLowerCase().includes(q) || e.content.toLowerCase().includes(q),
);
}, [entries, search]);
const grouped = useMemo(() => {
const groups: Record<string, MemoryEntry[]> = { operating: [], knowledge: [], session: [] };
for (const e of filtered) {
const layer = e.layer in groups ? e.layer : "knowledge";
groups[layer].push(e);
}
return groups;
}, [filtered]);
const toggleLayer = useCallback((layer: string) => {
setCollapsedLayers((prev) => {
const next = new Set(prev);
if (next.has(layer)) next.delete(layer);
else next.add(layer);
return next;
});
}, []);
return (
<div className="mt-4 space-y-4">
<MemoryToolbar
search={search}
onSearchChange={setSearch}
onClearAll={() => setClearDialogOpen(true)}
onExport={handleExport}
isEmpty={entries.length === 0}
/>
{entries.length === 0 ? (
<EmptyMemoryState />
) : (
<MemoryGroupsList
grouped={grouped}
collapsedLayers={collapsedLayers}
expandedId={expandedId}
onToggleLayer={toggleLayer}
onToggleEntry={(id) => setExpandedId(expandedId === id ? null : id)}
onDelete={handleDelete}
/>
)}
<ClearAllDialog
open={clearDialogOpen}
onOpenChange={setClearDialogOpen}
onConfirm={() => {
handleClearAll();
setClearDialogOpen(false);
}}
/>
</div>
);
}
function MemoryToolbar({
search,
onSearchChange,
onClearAll,
onExport,
isEmpty,
}: {
search: string;
onSearchChange: (v: string) => void;
onClearAll: () => void;
onExport: () => void;
isEmpty: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-[300px]">
<IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("office:searchMemory")}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<div className="ml-auto flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={onExport}
disabled={isEmpty}
className="cursor-pointer"
>
<IconDownload className="h-4 w-4 mr-1" />
{t("office:export")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={onClearAll}
disabled={isEmpty}
className="cursor-pointer"
>
{t("office:clearAll")}
</Button>
</div>
</div>
);
}
function MemoryLayerGroup({
layer,
entries,
collapsed,
expandedId,
onToggleLayer,
onToggleEntry,
onDelete,
}: {
layer: string;
entries: MemoryEntry[];
collapsed: boolean;
expandedId: string | null;
onToggleLayer: () => void;
onToggleEntry: (id: string) => void;
onDelete: (id: string) => void;
}) {
if (entries.length === 0) return null;
const Icon = collapsed ? IconChevronRight : IconChevronDown;
return (
<div>
<button
onClick={onToggleLayer}
className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-widest text-muted-foreground/60 mb-2 cursor-pointer"
>
<Icon className="h-3.5 w-3.5" />
{layer} ({entries.length})
</button>
{!collapsed && (
<div className="border border-border rounded-lg divide-y divide-border">
{entries.map((entry) => (
<MemoryEntryRow
key={entry.id}
entry={entry}
expanded={expandedId === entry.id}
onToggle={() => onToggleEntry(entry.id)}
onDelete={() => onDelete(entry.id)}
/>
))}
</div>
)}
</div>
);
}
function MemoryEntryRow({
entry,
expanded,
onToggle,
onDelete,
}: {
entry: MemoryEntry;
expanded: boolean;
onToggle: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<div className="px-4 py-2.5">
<div className="flex items-center gap-2 text-sm cursor-pointer" onClick={onToggle}>
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{entry.key}
</Badge>
<span className="flex-1 truncate text-muted-foreground">
{entry.content.slice(0, 80)}
{entry.content.length > 80 ? "..." : ""}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{entry.updated_at ? new Date(entry.updated_at).toLocaleDateString() : ""}
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("office:deleteEntry")}</TooltipContent>
</Tooltip>
</div>
{expanded && (
<pre className="mt-2 text-xs whitespace-pre-wrap bg-muted/50 rounded p-3">
{entry.content}
</pre>
)}
</div>
);
}
function ClearAllDialog({
open,
onOpenChange,
onConfirm,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("office:clearAllMemory")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("office:thisWillPermanentlyDeleteAllMemory")}
</p>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} className="cursor-pointer">
{t("common:cancel")}
</Button>
<Button variant="destructive" onClick={onConfirm} className="cursor-pointer">
{t("office:clearAll")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}