mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #34366 from BerriAI/litellm_/migrate-page-memory-9b2c09
refactor(ui): migrate memory page to shadcn
This commit is contained in:
commit
bd753aecf3
5 changed files with 212 additions and 115 deletions
|
|
@ -1109,21 +1109,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": {
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MemoryRow } from "@/components/networking";
|
||||
|
||||
import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
|
||||
|
||||
const makeMemory = (overrides: Partial<MemoryRow> = {}): MemoryRow => ({
|
||||
memory_id: "mem-1",
|
||||
key: "user:profile",
|
||||
value: "The user prefers concise answers.",
|
||||
metadata: null,
|
||||
user_id: "user-42",
|
||||
team_id: "team-7",
|
||||
created_at: "2024-05-01T12:00:00Z",
|
||||
updated_at: "2024-05-02T12:00:00Z",
|
||||
created_by: "alice",
|
||||
updated_by: "bob",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("MemoryDetailDrawer", () => {
|
||||
it("renders nothing until a row is selected", () => {
|
||||
render(<MemoryDetailDrawer row={null} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText("Memory ID")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Value")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selected row's key, identifiers and value", () => {
|
||||
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("user:profile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Memory ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("mem-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("User ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("user-42")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("team-7")).toBeInTheDocument();
|
||||
expect(screen.getByText("Value")).toBeInTheDocument();
|
||||
expect(screen.getByText("The user prefers concise answers.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to a dash for a memory with no owning user or team", () => {
|
||||
render(<MemoryDetailDrawer row={makeMemory({ user_id: null, team_id: null })} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText("-")).toHaveLength(2);
|
||||
expect(screen.queryByText("user-42")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the metadata block when the row carries no metadata", () => {
|
||||
render(<MemoryDetailDrawer row={makeMemory({ metadata: null })} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText("Metadata")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pretty-prints metadata as JSON when present", () => {
|
||||
render(<MemoryDetailDrawer row={makeMemory({ metadata: { tags: ["example"] } })} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Metadata")).toBeInTheDocument();
|
||||
expect(screen.getByText('{ "tags": [ "example" ] }')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("attributes the created and updated timestamps to their actors", () => {
|
||||
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an em dash for a timestamp the backend did not send", () => {
|
||||
render(<MemoryDetailDrawer row={makeMemory({ created_at: undefined, created_by: undefined })} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Created —")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes through the close control", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<MemoryDetailDrawer row={makeMemory()} onClose={onClose} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { Drawer, Space, Typography } from "antd";
|
||||
import React from "react";
|
||||
|
||||
import { MemoryRow } from "@/components/networking";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface MemoryDetailDrawerProps {
|
||||
row: MemoryRow | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CODE_CLASS = "rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground";
|
||||
const BLOCK_CLASS = "mt-1 rounded-md bg-muted p-3 font-mono whitespace-pre-wrap text-foreground";
|
||||
const LABEL_CLASS = "text-sm font-semibold text-foreground";
|
||||
|
||||
function formatTimestamp(ts?: string): string {
|
||||
if (!ts) return "—";
|
||||
try {
|
||||
|
|
@ -24,90 +26,61 @@ function formatTimestamp(ts?: string): string {
|
|||
|
||||
export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
|
||||
return (
|
||||
<Drawer
|
||||
<Sheet
|
||||
open={!!row}
|
||||
onClose={onClose}
|
||||
title={
|
||||
row ? (
|
||||
<Space>
|
||||
<Text code>{row.key}</Text>
|
||||
</Space>
|
||||
) : (
|
||||
"Memory"
|
||||
)
|
||||
}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
{row && (
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Space size="large" wrap>
|
||||
<div>
|
||||
<Text strong style={{ display: "block" }}>
|
||||
Memory ID
|
||||
</Text>
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{row.memory_id}
|
||||
</Text>
|
||||
<SheetContent className="overflow-y-auto data-[side=right]:w-full data-[side=right]:max-w-full data-[side=right]:sm:w-[720px] data-[side=right]:sm:max-w-full">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle>{row ? <code className={CODE_CLASS}>{row.key}</code> : "Memory"}</SheetTitle>
|
||||
</SheetHeader>
|
||||
{row && (
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
||||
<div>
|
||||
<span className={`block ${LABEL_CLASS}`}>Memory ID</span>
|
||||
<code className={CODE_CLASS}>{row.memory_id}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block ${LABEL_CLASS}`}>User ID</span>
|
||||
<span className={row.user_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
|
||||
{row.user_id ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block ${LABEL_CLASS}`}>Team ID</span>
|
||||
<span className={row.team_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
|
||||
{row.team_id ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: "block" }}>
|
||||
User ID
|
||||
</Text>
|
||||
<Text type={row.user_id ? undefined : "secondary"}>{row.user_id ?? "-"}</Text>
|
||||
<span className={LABEL_CLASS}>Value</span>
|
||||
<p className={`${BLOCK_CLASS} text-[13px]`}>{row.value}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: "block" }}>
|
||||
Team ID
|
||||
</Text>
|
||||
<Text type={row.team_id ? undefined : "secondary"}>{row.team_id ?? "-"}</Text>
|
||||
{row.metadata !== undefined && row.metadata !== null && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>Metadata</span>
|
||||
<p className={`${BLOCK_CLASS} text-xs`}>{JSON.stringify(row.metadata, null, 2)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Created {formatTimestamp(row.created_at)}
|
||||
{row.created_by ? ` by ${row.created_by}` : ""}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
Updated {formatTimestamp(row.updated_at)}
|
||||
{row.updated_by ? ` by ${row.updated_by}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</Space>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
{row.value}
|
||||
</Paragraph>
|
||||
</div>
|
||||
{row.metadata !== undefined && row.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(row.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(row.created_at)}
|
||||
{row.created_by ? ` by ${row.created_by}` : ""}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
Updated {formatTimestamp(row.updated_at)}
|
||||
{row.updated_by ? ` by ${row.updated_by}` : ""}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ interface CapturedTableProps {
|
|||
rowCount: number;
|
||||
data: MemoryRow[];
|
||||
hasActiveSearch: boolean;
|
||||
onViewClick: (row: MemoryRow) => void;
|
||||
}
|
||||
|
||||
const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null }));
|
||||
|
|
@ -42,4 +44,47 @@ describe("MemoryView", () => {
|
|||
expect(captured.current?.rowCount).toBe(0);
|
||||
expect(captured.current?.hasActiveSearch).toBe(false);
|
||||
});
|
||||
|
||||
it("heads the page with the Memory title and the /v1/memory scope note", () => {
|
||||
renderView(null);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Memory" })).toBeInTheDocument();
|
||||
expect(screen.getByText("/v1/memory")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Scoped to memories visible to your user \/ team \(admins see all\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the create modal from the New memory button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView(null);
|
||||
|
||||
expect(screen.queryByText("Create memory")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /new memory/i }));
|
||||
|
||||
expect(await screen.findByText("Create memory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the detail drawer for the row the table hands back, and closes it again", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderView(null);
|
||||
|
||||
expect(screen.queryByText("Memory ID")).not.toBeInTheDocument();
|
||||
|
||||
const row: MemoryRow = {
|
||||
memory_id: "mem-drawer",
|
||||
key: "user:profile",
|
||||
value: "remembered",
|
||||
metadata: null,
|
||||
user_id: null,
|
||||
team_id: null,
|
||||
};
|
||||
act(() => captured.current?.onViewClick(row));
|
||||
|
||||
expect(await screen.findByText("Memory ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("mem-drawer")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
expect(screen.queryByText("mem-drawer")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,20 +3,19 @@
|
|||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Space, Typography, message } from "antd";
|
||||
import { Plus } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
||||
import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
|
||||
import { MemoryEditModal } from "./MemoryEditModal";
|
||||
import { MemoryTable } from "./MemoryTable";
|
||||
|
||||
const { Text, Paragraph, Title } = Typography;
|
||||
|
||||
interface MemoryViewProps {
|
||||
accessToken: string | null;
|
||||
userID: string | null;
|
||||
|
|
@ -62,7 +61,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
// All three write endpoints share the same success/error plumbing:
|
||||
// - on success: invalidate the list query so every cached page
|
||||
// refetches from scratch (pagination + filter-aware).
|
||||
// - on error: surface the message via antd `message.error`.
|
||||
// - on error: surface the message via `MessageManager.error`.
|
||||
|
||||
const invalidateList = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: [MEMORY_LIST_KEY] }),
|
||||
|
|
@ -75,11 +74,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
return createMemory(accessToken, args);
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
message.success(`Created ${row.key}`);
|
||||
MessageManager.success(`Created ${row.key}`);
|
||||
invalidateList();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`Save failed: ${err.message}`);
|
||||
MessageManager.error(`Save failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -90,11 +89,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
return updateMemory(accessToken, key, payload);
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
message.success(`Updated ${row.key}`);
|
||||
MessageManager.success(`Updated ${row.key}`);
|
||||
invalidateList();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`Save failed: ${err.message}`);
|
||||
MessageManager.error(`Save failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -104,11 +103,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
return deleteMemory(accessToken, key).then(() => key);
|
||||
},
|
||||
onSuccess: (key) => {
|
||||
message.success(`Deleted ${key}`);
|
||||
MessageManager.success(`Deleted ${key}`);
|
||||
invalidateList();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`Delete failed: ${err.message}`);
|
||||
MessageManager.error(`Delete failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
try {
|
||||
metadataPayload = JSON.parse(metadataText);
|
||||
} catch {
|
||||
message.error("Metadata must be valid JSON (or leave empty).");
|
||||
MessageManager.error("Metadata must be valid JSON (or leave empty).");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -177,19 +176,21 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full" style={{ padding: 24 }}>
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16 }}>
|
||||
<div className="w-full p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Memory</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Inspect what your agents have stored under{" "}
|
||||
<code className="rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
|
||||
/v1/memory
|
||||
</code>
|
||||
. Scoped to memories visible to your user / team (admins see all).
|
||||
</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateOpen(true)}>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus />
|
||||
New memory
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -209,7 +210,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
onEditClick={handleEdit}
|
||||
onDeleteClick={handleDelete}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Detail drawer */}
|
||||
<MemoryDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue