feat(ui): add Memory page to view/manage /v1/memory entries

Adds a new "Memory" sidebar item under Tools so users can see what their
agents have stored. Lists all memories visible to the caller (scoped by
the backend), with a key-search filter, preview column, scope tags, and
view/edit/delete actions. Create modal accepts optional JSON metadata.

- networking.tsx: fetchMemoryList / createMemory / updateMemory / deleteMemory
  wired to the /v1/memory CRUD endpoints.
- MemoryView + MemoryEditModal: new antd-based components (per CLAUDE.md:
  use antd for new UI, not tremor).
- page.tsx + leftnav.tsx: wire the "memory" route + sidebar entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-04-23 08:17:16 -07:00
parent bd9e1ba0a0
commit b0b1ea3740
6 changed files with 671 additions and 0 deletions

View file

@ -39,6 +39,7 @@ import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage";
import { ProjectsPage } from "@/components/Projects/ProjectsPage";
import VectorStoreManagement from "@/components/vector_store_management";
import ToolPoliciesView from "@/components/ToolPoliciesView";
import { MemoryView } from "@/components/MemoryView";
import SpendLogsTable from "@/components/view_logs";
import ViewUserDashboard from "@/components/view_users";
import { ThemeProvider } from "@/contexts/ThemeContext";
@ -630,6 +631,12 @@ function CreateKeyPageContent() {
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tool-policies" ? (
<ToolPoliciesView accessToken={accessToken} userRole={userRole} />
) : page == "memory" ? (
<MemoryView
accessToken={accessToken}
userID={userID}
userRole={userRole}
/>
) : page == "guardrails-monitor" ? (
<GuardrailsMonitorView accessToken={accessToken} />
) : page == "new_usage" ? (

View file

@ -0,0 +1,121 @@
"use client";
import React, { useEffect, useState } from "react";
import { Form, Input, Modal, Typography } from "antd";
import type { MemoryRow } from "../networking";
const { Text } = Typography;
interface MemoryEditModalProps {
open: boolean;
mode: "create" | "edit";
initialRow?: MemoryRow;
onClose: () => void;
onSave: (
key: string,
value: string,
metadataText: string,
isCreate: boolean,
) => Promise<boolean>;
}
export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({
open,
mode,
initialRow,
onClose,
onSave,
}) => {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
if (mode === "edit" && initialRow) {
form.setFieldsValue({
key: initialRow.key,
value: initialRow.value,
metadata:
initialRow.metadata != null
? JSON.stringify(initialRow.metadata, null, 2)
: "",
});
} else {
form.resetFields();
}
}, [open, mode, initialRow, form]);
const handleOk = async () => {
const values = await form.validateFields();
setSubmitting(true);
const ok = await onSave(
values.key.trim(),
values.value ?? "",
values.metadata ?? "",
mode === "create",
);
setSubmitting(false);
if (ok) {
form.resetFields();
onClose();
}
};
return (
<Modal
open={open}
title={mode === "create" ? "Create memory" : `Edit ${initialRow?.key ?? ""}`}
onCancel={() => {
form.resetFields();
onClose();
}}
onOk={handleOk}
okText={mode === "create" ? "Create" : "Save"}
confirmLoading={submitting}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
label="Key"
name="key"
rules={[{ required: true, message: "Key is required" }]}
tooltip="Unique per user/team scope. Acts as the namespace in /v1/memory/{key}."
>
<Input
placeholder="e.g. user_role"
disabled={mode === "edit"}
/>
</Form.Item>
<Form.Item
label="Value"
name="value"
rules={[{ required: true, message: "Value is required" }]}
tooltip="Markdown/text injected into LLM context. Plain strings are fine."
>
<Input.TextArea
rows={8}
placeholder="What the agent should remember…"
/>
</Form.Item>
<Form.Item
label={
<span>
Metadata <Text type="secondary">(optional JSON)</Text>
</span>
}
name="metadata"
tooltip="Optional structured metadata — must be valid JSON if provided."
>
<Input.TextArea
rows={4}
placeholder='{"tags": ["example"]}'
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default MemoryEditModal;

View file

@ -0,0 +1,419 @@
"use client";
import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Button,
Card,
Drawer,
Empty,
Input,
Modal,
Space,
Table,
Tag,
Typography,
message,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import {
DeleteOutlined,
EditOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
} from "@ant-design/icons";
import {
MemoryRow,
createMemory,
deleteMemory,
fetchMemoryList,
updateMemory,
} from "../networking";
import { MemoryEditModal } from "./MemoryEditModal";
const { Text, Paragraph, Title } = Typography;
interface MemoryViewProps {
accessToken: string | null;
userID: string | null;
userRole: string | null;
}
function previewValue(value: string, max = 120): string {
if (!value) return "";
const trimmed = value.trim();
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max)}`;
}
function formatTimestamp(ts?: string): string {
if (!ts) return "—";
try {
const d = new Date(ts);
return d.toLocaleString();
} catch {
return ts;
}
}
export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
const [searchInput, setSearchInput] = useState("");
const [appliedSearch, setAppliedSearch] = useState("");
const [detailRow, setDetailRow] = useState<MemoryRow | null>(null);
const [editRow, setEditRow] = useState<MemoryRow | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const {
data,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["memoryList", appliedSearch],
queryFn: () => {
if (!accessToken) throw new Error("Access token required");
// Current API supports exact key match; treat empty search as list-all.
return fetchMemoryList(accessToken, {
keyPrefix: appliedSearch || undefined,
pageSize: 200,
});
},
enabled: !!accessToken,
});
const rows = useMemo(() => data?.memories ?? [], [data]);
const handleDelete = async (row: MemoryRow) => {
Modal.confirm({
title: "Delete memory",
content: (
<span>
Delete memory key <Text code>{row.key}</Text>? This cannot be undone.
</span>
),
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: async () => {
if (!accessToken) return;
try {
await deleteMemory(accessToken, row.key);
message.success(`Deleted ${row.key}`);
refetch();
} catch (err: any) {
message.error(`Delete failed: ${err?.message ?? err}`);
}
},
});
};
const handleSave = async (
key: string,
value: string,
metadataText: string,
isCreate: boolean,
): Promise<boolean> => {
if (!accessToken) return false;
let metadataParsed: unknown | undefined;
if (metadataText.trim()) {
try {
metadataParsed = JSON.parse(metadataText);
} catch {
message.error("Metadata must be valid JSON (or leave empty).");
return false;
}
}
try {
if (isCreate) {
await createMemory(accessToken, {
key,
value,
metadata: metadataParsed,
});
message.success(`Created ${key}`);
} else {
await updateMemory(accessToken, key, {
value,
metadata: metadataParsed,
});
message.success(`Updated ${key}`);
}
refetch();
return true;
} catch (err: any) {
message.error(`Save failed: ${err?.message ?? err}`);
return false;
}
};
const columns: ColumnsType<MemoryRow> = [
{
title: "Key",
dataIndex: "key",
key: "key",
render: (k: string) => <Text code>{k}</Text>,
sorter: (a, b) => a.key.localeCompare(b.key),
width: 240,
},
{
title: "Preview",
dataIndex: "value",
key: "value",
render: (v: string) => (
<Text type="secondary" style={{ whiteSpace: "pre-wrap" }}>
{previewValue(v)}
</Text>
),
},
{
title: "Scope",
key: "scope",
width: 220,
render: (_: unknown, r: MemoryRow) => (
<Space size={4} wrap>
{r.user_id && <Tag color="blue">user: {r.user_id}</Tag>}
{r.team_id && <Tag color="purple">team: {r.team_id}</Tag>}
{!r.user_id && !r.team_id && <Tag>global</Tag>}
</Space>
),
},
{
title: "Updated",
dataIndex: "updated_at",
key: "updated_at",
width: 180,
render: (ts?: string) => (
<Text type="secondary">{formatTimestamp(ts)}</Text>
),
sorter: (a, b) =>
new Date(a.updated_at ?? 0).getTime() -
new Date(b.updated_at ?? 0).getTime(),
defaultSortOrder: "descend",
},
{
title: "",
key: "actions",
width: 140,
render: (_: unknown, r: MemoryRow) => (
<Space size={4}>
<Button
size="small"
type="text"
icon={<EyeOutlined />}
onClick={() => setDetailRow(r)}
aria-label="View"
/>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => setEditRow(r)}
aria-label="Edit"
/>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(r)}
aria-label="Delete"
/>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<Space
direction="vertical"
size="large"
style={{ width: "100%" }}
>
<div>
<Title level={3} style={{ marginBottom: 4 }}>
Memory
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
Inspect what your agents have stored under <Text code>/v1/memory</Text>.
Scoped to memories visible to your user / team (admins see all).
</Paragraph>
</div>
<Card>
<Space
style={{
width: "100%",
justifyContent: "space-between",
marginBottom: 16,
}}
wrap
>
<Space>
<Input
allowClear
placeholder="Filter by exact key"
prefix={<SearchOutlined />}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onPressEnter={() => setAppliedSearch(searchInput.trim())}
onClear={() => {
setSearchInput("");
setAppliedSearch("");
}}
style={{ width: 280 }}
/>
<Button
type="primary"
ghost
onClick={() => setAppliedSearch(searchInput.trim())}
>
Search
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => refetch()}
loading={isFetching && !isLoading}
>
Refresh
</Button>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateOpen(true)}
>
New memory
</Button>
</Space>
<Table
rowKey="memory_id"
loading={isLoading}
dataSource={rows}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: true }}
locale={{
emptyText: (
<Empty
description={
appliedSearch
? `No memories found for key "${appliedSearch}"`
: "No memories stored yet"
}
/>
),
}}
/>
{data?.total !== undefined && (
<Text type="secondary">
Showing {rows.length} of {data.total}
</Text>
)}
</Card>
</Space>
{/* Detail drawer */}
<Drawer
open={!!detailRow}
onClose={() => setDetailRow(null)}
title={
detailRow ? (
<Space>
<Text code>{detailRow.key}</Text>
</Space>
) : (
"Memory"
)
}
width={720}
destroyOnClose
>
{detailRow && (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div>
<Text strong>Scope</Text>
<div>
<Space size={4} wrap>
{detailRow.user_id && (
<Tag color="blue">user: {detailRow.user_id}</Tag>
)}
{detailRow.team_id && (
<Tag color="purple">team: {detailRow.team_id}</Tag>
)}
{!detailRow.user_id && !detailRow.team_id && <Tag>global</Tag>}
</Space>
</div>
</div>
<div>
<Text strong>Value</Text>
<Paragraph
style={{
background: "#fafafa",
padding: 12,
borderRadius: 6,
whiteSpace: "pre-wrap",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 13,
}}
>
{detailRow.value}
</Paragraph>
</div>
{detailRow.metadata !== undefined &&
detailRow.metadata !== null && (
<div>
<Text strong>Metadata</Text>
<Paragraph
style={{
background: "#fafafa",
padding: 12,
borderRadius: 6,
whiteSpace: "pre-wrap",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12,
}}
>
{JSON.stringify(detailRow.metadata, null, 2)}
</Paragraph>
</div>
)}
<Space
split={<Text type="secondary">·</Text>}
wrap
size="small"
style={{ color: "rgba(0,0,0,0.45)" }}
>
<Text type="secondary">
Created {formatTimestamp(detailRow.created_at)}
{detailRow.created_by ? ` by ${detailRow.created_by}` : ""}
</Text>
<Text type="secondary">
Updated {formatTimestamp(detailRow.updated_at)}
{detailRow.updated_by ? ` by ${detailRow.updated_by}` : ""}
</Text>
</Space>
</Space>
)}
</Drawer>
{/* Create / edit modal */}
<MemoryEditModal
open={isCreateOpen || !!editRow}
mode={editRow ? "edit" : "create"}
initialRow={editRow ?? undefined}
onClose={() => {
setIsCreateOpen(false);
setEditRow(null);
}}
onSave={handleSave}
/>
</div>
);
};
export default MemoryView;

View file

@ -0,0 +1 @@
export { MemoryView, default } from "./MemoryView";

View file

@ -180,6 +180,12 @@ const menuGroups: MenuGroup[] = [
label: "Tool Policies",
icon: <SafetyOutlined />,
},
{
key: "memory",
page: "memory",
label: "Memory",
icon: <BookOutlined />,
},
],
},
],

View file

@ -9986,3 +9986,120 @@ export const listMCPUserCredentials = async (
if (!response.ok) return [];
return response.json();
};
// ============================================================
// Memory management (/v1/memory)
// ============================================================
export interface MemoryRow {
memory_id: string;
key: string;
value: string;
metadata?: unknown;
user_id?: string | null;
team_id?: string | null;
created_at?: string;
created_by?: string | null;
updated_at?: string;
updated_by?: string | null;
}
export interface MemoryListResponse {
memories: MemoryRow[];
total: number;
}
export const fetchMemoryList = async (
accessToken: string,
options: { keyPrefix?: string; page?: number; pageSize?: number } = {},
): Promise<MemoryListResponse> => {
const base = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`;
const params = new URLSearchParams();
if (options.keyPrefix) params.append("key", options.keyPrefix);
if (options.page != null) params.append("page", String(options.page));
if (options.pageSize != null)
params.append("page_size", String(options.pageSize));
const url = params.toString() ? `${base}?${params.toString()}` : base;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(errorData);
}
return response.json();
};
export const createMemory = async (
accessToken: string,
payload: { key: string; value: string; metadata?: unknown },
): Promise<MemoryRow> => {
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`;
const body: Record<string, unknown> = {
key: payload.key,
value: payload.value,
};
if (payload.metadata !== undefined) body.metadata = payload.metadata;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(errorData);
}
return response.json();
};
export const updateMemory = async (
accessToken: string,
key: string,
payload: { value?: string; metadata?: unknown },
): Promise<MemoryRow> => {
const encoded = encodeURIComponent(key);
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/memory/${encoded}`
: `/v1/memory/${encoded}`;
const response = await fetch(url, {
method: "PUT",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(errorData);
}
return response.json();
};
export const deleteMemory = async (
accessToken: string,
key: string,
): Promise<void> => {
const encoded = encodeURIComponent(key);
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/memory/${encoded}`
: `/v1/memory/${encoded}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(errorData);
}
};