mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
feat(memory): add user activation and contributor table
This commit is contained in:
parent
6f92e244fb
commit
f4fc27f2e2
13 changed files with 497 additions and 190 deletions
|
|
@ -1,3 +1,4 @@
|
|||
from types import MappingProxyType
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
|
@ -225,7 +226,15 @@ async def get_status(
|
|||
key_id: str | None = Query(None, pattern=r"^[a-f0-9]{64}$"),
|
||||
auth: UserAPIKeyAuth = _AUTH,
|
||||
) -> MemoryStatus:
|
||||
return (await access_for_key(auth, key_id)).status
|
||||
access: Final = await access_for_key(auth, key_id)
|
||||
user: Final = (
|
||||
await UserRepository(memory_primary_client(require_memory_prisma())).find_by_id(access.identity.user_id)
|
||||
if access.identity.user_id
|
||||
else None
|
||||
)
|
||||
return access.status.model_copy(
|
||||
update=MappingProxyType({"user_name": user.user_alias or user.user_email or user.user_id if user else None})
|
||||
)
|
||||
|
||||
|
||||
async def access_for_key(auth: UserAPIKeyAuth, key_id: str | None) -> MemoryAccess:
|
||||
|
|
@ -267,12 +276,25 @@ async def list_entries(
|
|||
raise HTTPException(status_code=422, detail="Provide both memory cursor fields")
|
||||
prisma: Final = memory_primary_client(require_memory_prisma())
|
||||
access: Final = await access_for_key(auth, key_id)
|
||||
return await MemoryStore(prisma, access).search(
|
||||
entries: Final = await MemoryStore(prisma, access).search(
|
||||
MemorySearch(query=query, limit=limit, offset=offset),
|
||||
require_active=False,
|
||||
recent_first=True,
|
||||
before=(before_updated_at, before_memory_id) if before_updated_at and before_memory_id else None,
|
||||
)
|
||||
actors: Final = tuple(frozenset(entry.actor for entry in entries if entry.actor))
|
||||
if not actors:
|
||||
return entries
|
||||
users: Final = await UserRepository(prisma).table.find_many(
|
||||
where={"user_id": {"in": list(actors)}}, # mutable-ok: Prisma serializes the bounded page's contributor IDs.
|
||||
take=len(actors),
|
||||
)
|
||||
names: Final = MappingProxyType(
|
||||
{user.user_id: user.user_alias or user.user_email or user.user_id for user in users}
|
||||
)
|
||||
return tuple(
|
||||
entry.model_copy(update=MappingProxyType({"actor_name": names.get(entry.actor or "")})) for entry in entries
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entries", response_model=MemoryEntry)
|
||||
|
|
@ -283,7 +305,9 @@ async def capture_entry(
|
|||
) -> MemoryEntry:
|
||||
prisma: Final = memory_primary_client(require_memory_prisma())
|
||||
access: Final = await access_for_key(auth, key_id)
|
||||
return await MemoryStore(prisma, access).capture(capture)
|
||||
return await MemoryStore(prisma, access, actor=auth.user_id or MemoryIdentity.from_auth(auth).key_id).capture(
|
||||
capture
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/entries/{memory_id}", status_code=204)
|
||||
|
|
|
|||
|
|
@ -82,10 +82,10 @@ class _LegacyMemoryVisibility(TypedDict):
|
|||
def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object] | None:
|
||||
"""
|
||||
Prisma `where` fragment restricting rows to those the caller can see.
|
||||
Returns None for admins (no restriction).
|
||||
Administrators can access every V1 row, independently of user/team ownership.
|
||||
"""
|
||||
if user_api_key_has_admin_view(user_api_key_dict):
|
||||
return None
|
||||
return {"namespace": None} # mutable-ok: Prisma requires a native JSON filter for V1 visibility.
|
||||
ors: Final = [
|
||||
{field: value}
|
||||
for field, value in (("user_id", user_api_key_dict.user_id), ("team_id", user_api_key_dict.team_id))
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ class MemoryAccess:
|
|||
scope=self.policy.scope if self.policy and self.namespace else None,
|
||||
opted_in=self.opted_in,
|
||||
policy_id=self.policy.policy_id if self.policy else None,
|
||||
user_id=self.identity.user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -50,9 +50,10 @@ def memory_entry(row: "LiteLLM_MemoryTable") -> MemoryEntry:
|
|||
|
||||
|
||||
class MemoryStore:
|
||||
def __init__(self, prisma_client: object, access: MemoryAccess) -> None:
|
||||
def __init__(self, prisma_client: object, access: MemoryAccess, *, actor: str | None = None) -> None:
|
||||
self.prisma_client = memory_primary_client(prisma_client)
|
||||
self.access = access
|
||||
self.actor = actor or access.identity.user_id or access.identity.key_id
|
||||
self.table = MemoryRepository(self.prisma_client).table
|
||||
|
||||
async def authorize_namespace(self, *, write: bool = False, require_active: bool = True) -> str:
|
||||
|
|
@ -159,7 +160,7 @@ class MemoryStore:
|
|||
data: Final = { # mutable-ok: Prisma query and write JSON.
|
||||
"value": content,
|
||||
"metadata": json.dumps(metadata),
|
||||
"updated_by": self.access.identity.user_id or self.access.identity.key_id,
|
||||
"updated_by": self.actor,
|
||||
}
|
||||
existing: Final = await table.find_unique(
|
||||
where={ # mutable-ok: Prisma query and write JSON.
|
||||
|
|
@ -215,7 +216,7 @@ class MemoryStore:
|
|||
"namespace": namespace,
|
||||
"user_id": self.access.identity.user_id,
|
||||
"team_id": self.access.identity.team_id,
|
||||
"created_by": self.access.identity.user_id or self.access.identity.key_id,
|
||||
"created_by": self.actor,
|
||||
}
|
||||
)
|
||||
return memory_entry(created)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class MemoryPolicyInput(BaseModel):
|
|||
target_type: MemoryTarget
|
||||
target_id: str = Field(min_length=1, max_length=256)
|
||||
activation: MemoryActivation
|
||||
scope: MemoryScope = "key"
|
||||
scope: MemoryScope = "user"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target(self) -> Self:
|
||||
|
|
@ -62,6 +62,8 @@ class MemoryStatus(BaseModel):
|
|||
scope: MemoryScope | None
|
||||
opted_in: bool
|
||||
policy_id: str | None
|
||||
user_id: str | None = None
|
||||
user_name: str | None = None
|
||||
|
||||
|
||||
class MemoryCapture(BaseModel):
|
||||
|
|
@ -90,6 +92,7 @@ class MemoryEntry(BaseModel):
|
|||
updated_at: datetime
|
||||
created_at: datetime | None = None
|
||||
actor: str | None = None
|
||||
actor_name: str | None = None
|
||||
when_to_use: str = ""
|
||||
scope: str = ""
|
||||
kind: MemoryKind = "context"
|
||||
|
|
|
|||
|
|
@ -1088,14 +1088,30 @@ class TestMemoryEndpoints:
|
|||
assert resp.status_code == 404, resp.text
|
||||
assert resp.json()["detail"] == "Memory with key 'notes' not found"
|
||||
|
||||
def test_visibility_filter_unscoped_for_admin_viewer(self):
|
||||
def test_visibility_filter_excludes_v2_for_admin_viewer(self):
|
||||
"""
|
||||
proxy_admin_viewer reads with the same unscoped filter as proxy_admin;
|
||||
every other role stays row-restricted.
|
||||
Administrative reads include every V1 owner but exclude V2 namespaces.
|
||||
"""
|
||||
assert _visibility_filter(_admin_viewer_auth()) is None
|
||||
assert _visibility_filter(_admin_viewer_auth()) == {"namespace": None}
|
||||
assert _visibility_filter(_user_auth("user-a", "team-a")) is not None
|
||||
|
||||
def test_v1_admin_crud_does_not_read_or_change_v2_memories(self):
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
automatic = _make_row(memory_id="automatic", key="memory-v2:private:note", value="Private context")
|
||||
automatic.namespace = "private"
|
||||
table.rows.extend([_make_row(key="original-v1", value="Existing integration"), automatic])
|
||||
client = _make_client(_admin_auth())
|
||||
with _patch_prisma(self.prisma):
|
||||
listed = client.get("/v1/memory")
|
||||
assert listed.status_code == 200
|
||||
assert [row["key"] for row in listed.json()["memories"]] == ["original-v1"]
|
||||
assert client.get("/v1/memory/memory-v2:private:note").status_code == 404
|
||||
assert client.delete("/v1/memory/memory-v2:private:note").status_code == 404
|
||||
assert client.put("/v1/memory/memory-v2:private:note", json={"value": "Overwrite"}).status_code == 409
|
||||
updated = client.put("/v1/memory/original-v1", json={"value": "Still works", "metadata": None})
|
||||
assert updated.status_code == 200 and updated.json()["value"] == "Still works"
|
||||
assert automatic.value == "Private context"
|
||||
|
||||
def test_list_memory_admin_viewer_sees_all(self):
|
||||
"""Read parity end-to-end: the viewer's own user_id/team_id must not filter the list."""
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from fastapi.testclient import TestClient
|
|||
from litellm.proxy._types import UI_TEAM_ID, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.memory import management
|
||||
from litellm.proxy.memory.policy import MemoryIdentity, memory_digest
|
||||
from litellm.proxy.memory.policy import MemoryIdentity, memory_digest, resolve_memory_access
|
||||
from litellm.types.memory_v2 import MemoryCapture, MemoryPolicy, MemoryPolicyInput, MemoryPreference
|
||||
|
||||
|
||||
|
|
@ -96,6 +96,82 @@ def policy(**changes: object) -> MemoryPolicy:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_preference_and_memories_follow_owner_across_keys(database: MagicMock) -> None:
|
||||
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="user", activation="opt_in")]
|
||||
first = MemoryIdentity.from_auth(auth())
|
||||
second = MemoryIdentity.from_auth(auth().model_copy(update={"token": "b" * 64}))
|
||||
other = MemoryIdentity.from_auth(auth("other").model_copy(update={"token": "c" * 64}))
|
||||
other_org = MemoryIdentity.from_auth(auth().model_copy(update={"org_id": "other-org"}))
|
||||
assert not (await resolve_memory_access(database, first)).active
|
||||
await management.set_preference(MemoryPreference(enabled=True), auth())
|
||||
written = database.db.litellm_memorypreference.upsert.call_args.kwargs["data"]["create"]
|
||||
|
||||
async def preference(*, where: dict[str, str]) -> SimpleNamespace | None:
|
||||
return SimpleNamespace(enabled=written["enabled"]) if where["subject"] == written["subject"] else None
|
||||
|
||||
database.db.litellm_memorypreference.find_unique.side_effect = preference
|
||||
one = await resolve_memory_access(database, first)
|
||||
two = await resolve_memory_access(database, second)
|
||||
assert one.active and two.active and one.namespace == two.namespace
|
||||
assert not (await resolve_memory_access(database, other)).active
|
||||
assert one.namespace != (await resolve_memory_access(database, other_org)).namespace
|
||||
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="user", activation="disabled")]
|
||||
assert not (await resolve_memory_access(database, second)).active
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_resolves_saved_contributor_names_and_identifies_preference_owner(database: MagicMock) -> None:
|
||||
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="team")]
|
||||
database.db.litellm_usertable.find_unique.return_value = {"user_id": "owner", "user_alias": "Alex Rivera"}
|
||||
status = await management.get_status(None, auth())
|
||||
assert status.user_id == "owner" and status.user_name == "Alex Rivera"
|
||||
now = datetime(2026, 9, 14, tzinfo=timezone.utc)
|
||||
database.db.litellm_memorytable.find_many.return_value = [
|
||||
SimpleNamespace(
|
||||
memory_id="entry",
|
||||
key="memory-v2:namespace:demo",
|
||||
value="Use port 8123",
|
||||
metadata={},
|
||||
updated_at=now,
|
||||
created_at=now,
|
||||
created_by="contributor",
|
||||
)
|
||||
]
|
||||
database.db.litellm_usertable.find_many.return_value = [
|
||||
SimpleNamespace(user_id="contributor", user_alias="Jamie Davis", user_email=None)
|
||||
]
|
||||
entries = await management.list_entries("", 20, 0, None, auth())
|
||||
assert entries[0].actor == "contributor" and entries[0].actor_name == "Jamie Davis"
|
||||
assert database.db.litellm_usertable.find_many.call_args.kwargs["where"] == {"user_id": {"in": ["contributor"]}}
|
||||
database.db.litellm_usertable.find_many.return_value = []
|
||||
legacy = await management.list_entries("", 20, 0, None, auth())
|
||||
assert legacy[0].actor == "contributor" and legacy[0].actor_name is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_manual_capture_uses_actual_author_instead_of_selected_key_owner(database: MagicMock) -> None:
|
||||
database.db.litellm_memorypolicy.find_many.return_value = [policy(scope="user")]
|
||||
now = datetime(2026, 9, 14, tzinfo=timezone.utc)
|
||||
database.db.litellm_memorytable.create.return_value = SimpleNamespace(
|
||||
memory_id="entry",
|
||||
key="memory-v2:namespace:demo",
|
||||
value="Use port 8123",
|
||||
metadata={},
|
||||
updated_at=now,
|
||||
created_at=now,
|
||||
created_by="admin",
|
||||
)
|
||||
saved = await management.capture_entry(
|
||||
MemoryCapture(key="demo", title="Demo", content="Use port 8123", evidence="Admin correction"),
|
||||
"a" * 64,
|
||||
auth("admin", LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
data = database.db.litellm_memorytable.create.call_args.kwargs["data"]
|
||||
assert data["created_by"] == data["updated_by"] == saved.actor == "admin"
|
||||
assert data["user_id"] == "owner"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"target,target_id",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Brain, ChevronDown, Search } from "lucide-react";
|
||||
import { Brain } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -19,6 +17,7 @@ import { toast } from "@/lib/toast";
|
|||
|
||||
import { MemoryPreference } from "./MemorySettings";
|
||||
import { MemoryKeyPicker } from "./MemoryTargetPicker";
|
||||
import { MemoryEntriesTable } from "./MemoryEntriesTable";
|
||||
|
||||
type Entry = components["schemas"]["MemoryEntry"];
|
||||
type Capture = components["schemas"]["MemoryCapture"];
|
||||
|
|
@ -28,10 +27,10 @@ type Status = components["schemas"]["MemoryStatus"];
|
|||
function memoryDescription(status?: Status) {
|
||||
if (!status) return "What your assistants remember across conversations.";
|
||||
if (status.activation === "disabled" || !status.scope)
|
||||
return "Memory is off. Your administrator can make it available for this key.";
|
||||
return "Memory is off. Your administrator can make it available.";
|
||||
if (status.activation === "automatic") {
|
||||
if (status.active) return "Memory is on for this key. Your administrator manages this setting.";
|
||||
return "Memory is off. Your administrator can make it available for this key.";
|
||||
if (status.active) return "Memory is on. Your administrator manages this setting.";
|
||||
return "Memory is off. Your administrator can make it available.";
|
||||
}
|
||||
if (status.active) return "Your assistants can save and recall memories. You can turn this off at any time.";
|
||||
return "Your assistants won't save or recall memories. Turn it on whenever you're ready.";
|
||||
|
|
@ -48,21 +47,6 @@ function loadMoreLabel(fetching: boolean, failed: boolean) {
|
|||
return failed ? "Try again" : "Load more memories";
|
||||
}
|
||||
|
||||
function memoryDetails(entry: Entry) {
|
||||
const details = {
|
||||
Evidence: entry.evidence,
|
||||
"When to use": entry.when_to_use,
|
||||
Source: entry.source,
|
||||
Kind: entry.kind,
|
||||
Certainty: entry.certainty,
|
||||
Context: entry.scope,
|
||||
"Saved by": entry.actor,
|
||||
Created: entry.created_at ? new Date(entry.created_at).toLocaleString() : undefined,
|
||||
Updated: new Date(entry.updated_at).toLocaleString(),
|
||||
"Memory ID": entry.memory_id,
|
||||
};
|
||||
return Object.entries(details).filter(([, value]) => !!value);
|
||||
}
|
||||
type DashboardProps = Readonly<{ userId: string; readOnly: boolean; proxyAdmin: boolean }>;
|
||||
|
||||
export function AutomaticMemoryEntries({ userId, readOnly, proxyAdmin }: DashboardProps) {
|
||||
|
|
@ -80,7 +64,7 @@ export function AutomaticMemoryEntries({ userId, readOnly, proxyAdmin }: Dashboa
|
|||
<MemoryDashboard key={`${userId}:${keyId}`} userId={userId} keyId={keyId} readOnly={readOnly}>
|
||||
<div className="w-full space-y-1.5 sm:w-80">
|
||||
<Label htmlFor="memory-entry-key" className="text-xs text-muted-foreground">
|
||||
Virtual key
|
||||
Key context
|
||||
</Label>
|
||||
<MemoryKeyPicker
|
||||
inputId="memory-entry-key"
|
||||
|
|
@ -117,15 +101,18 @@ function MemoryDashboard({
|
|||
const [query, setQuery] = useState("");
|
||||
const [editing, setEditing] = useState<Entry | null>(null);
|
||||
const [deleting, setDeleting] = useState<Entry | null>(null);
|
||||
const status = useQuery({
|
||||
const statusOptions = {
|
||||
queryKey: ["memoryStatus", userId, keyId],
|
||||
enabled: !!keyId,
|
||||
queryFn: async ({ signal }) =>
|
||||
refetchOnMount: true,
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) =>
|
||||
(await fetchClient.GET("/v2/memory/status", { params: { query: { key_id: keyId } }, signal })).data,
|
||||
});
|
||||
};
|
||||
const status = useQuery(statusOptions);
|
||||
const entriesOptions = {
|
||||
queryKey: ["memoryEntries", userId, keyId, query],
|
||||
enabled: !!keyId && !!status.data?.scope,
|
||||
refetchOnMount: true,
|
||||
initialPageParam: null as Cursor,
|
||||
queryFn: async ({ signal, pageParam }: { signal: AbortSignal; pageParam: Cursor }) =>
|
||||
(
|
||||
|
|
@ -148,7 +135,7 @@ function MemoryDashboard({
|
|||
onSuccess: () => {
|
||||
setEditing(null);
|
||||
toast.success("Memory updated");
|
||||
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, keyId] });
|
||||
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId] });
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
|
@ -160,7 +147,7 @@ function MemoryDashboard({
|
|||
onSuccess: () => {
|
||||
setDeleting(null);
|
||||
toast.success("Memory deleted");
|
||||
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId, keyId] });
|
||||
return cache.invalidateQueries({ queryKey: ["memoryEntries", userId] });
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message),
|
||||
});
|
||||
|
|
@ -202,17 +189,12 @@ function MemoryDashboard({
|
|||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
{children}
|
||||
{status.data?.scope && (
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search memories"
|
||||
placeholder="Search memories"
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{status.data?.user_id && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Memory for{" "}
|
||||
<span className="font-medium text-foreground">{status.data.user_name ?? status.data.user_id}</span>
|
||||
{status.data.scope === "user" && ". Shared across this user's keys in this organization."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{status.error && (
|
||||
|
|
@ -221,106 +203,55 @@ function MemoryDashboard({
|
|||
</p>
|
||||
)}
|
||||
{status.data?.scope && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-base font-semibold">Saved memories</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status.data.scope !== "key" &&
|
||||
status.data.scope !== "user" &&
|
||||
`Shared with your ${status.data.scope} · `}
|
||||
Newest first
|
||||
</span>
|
||||
</div>
|
||||
{!status.data.active && memories.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">Saved memories stay here while memory is off.</p>
|
||||
)}
|
||||
{entries.isPending && (
|
||||
<div role="status" aria-label="Loading memories" className="space-y-3">
|
||||
{[0, 1, 2].map((row) => (
|
||||
<Skeleton key={row} className="h-28 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entries.isSuccess && memories.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border bg-card px-6 py-12 text-center">
|
||||
<Brain className="size-7 text-muted-foreground" />
|
||||
<h3 className="font-medium">{query ? "No matching memories" : "No memories yet"}</h3>
|
||||
<p className="text-sm text-muted-foreground">{emptyDescription(query, status.data.active)}</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-3" aria-label="Saved memories">
|
||||
{memories.map((entry) => (
|
||||
<li key={entry.memory_id} className="space-y-3 rounded-lg border bg-card p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="font-medium">{entry.title}</h3>
|
||||
<time
|
||||
dateTime={entry.updated_at}
|
||||
title={new Date(entry.updated_at).toLocaleString()}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{new Date(entry.updated_at).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</time>
|
||||
<div className="space-y-3">
|
||||
<h2 className="sr-only">Saved memories</h2>
|
||||
<MemoryEntriesTable
|
||||
entries={memories}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
loading={entries.isPending}
|
||||
readOnly={readOnly}
|
||||
canEdit={status.data.active}
|
||||
busy={busy}
|
||||
onEdit={setEditing}
|
||||
onDelete={setDeleting}
|
||||
empty={
|
||||
entries.error ? (
|
||||
<p className="text-destructive">Could not load memories: {entries.error.message}</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Brain className="mb-1 size-6 text-muted-foreground" />
|
||||
<h3 className="font-medium">{query ? "No matching memories" : "No memories yet"}</h3>
|
||||
<p className="text-sm text-muted-foreground">{emptyDescription(query, status.data.active)}</p>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{entry.content}</p>
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger
|
||||
render={<Button variant="ghost" size="xs" className="gap-1 text-muted-foreground" />}
|
||||
aria-label={`Details for ${entry.title}`}
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{memories.length} memories
|
||||
{!status.data.active && memories.length > 0 && " · Saved memories stay here while memory is off"}
|
||||
</span>
|
||||
{(entries.hasNextPage || entries.isError) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={entries.isFetching}
|
||||
onClick={() =>
|
||||
entries.isError && !entries.isFetchNextPageError ? entries.refetch() : entries.fetchNextPage()
|
||||
}
|
||||
>
|
||||
<ChevronDown className="size-3" /> Details
|
||||
<span className="sr-only"> for {entry.title}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<dl className="mt-3 grid gap-x-6 gap-y-2 border-t pt-3 text-xs sm:grid-cols-[auto_1fr]">
|
||||
{memoryDetails(entry).map(([label, value]) => (
|
||||
<div key={label} className="contents">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap break-all">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{!readOnly && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !status.data?.active}
|
||||
onClick={() => setEditing(entry)}
|
||||
>
|
||||
Edit memory
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setDeleting(entry)}>
|
||||
Delete memory
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{entries.error && (
|
||||
{loadMoreLabel(entries.isFetching, entries.isError)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{entries.isFetchNextPageError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load memories: {entries.error.message}
|
||||
Could not load more memories: {entries.error.message}
|
||||
</p>
|
||||
)}
|
||||
{(entries.hasNextPage || entries.isError) && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={entries.isFetching}
|
||||
onClick={() =>
|
||||
entries.isError && !entries.isFetchNextPageError ? entries.refetch() : entries.fetchNextPage()
|
||||
}
|
||||
>
|
||||
{loadMoreLabel(entries.isFetching, entries.isError)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { ChevronDown, ChevronRight, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
type Entry = components["schemas"]["MemoryEntry"];
|
||||
|
||||
function contributor(entry: Entry) {
|
||||
if (entry.actor_name) return entry.actor_name;
|
||||
if (!entry.actor) return "Unknown contributor";
|
||||
return /^[a-f0-9]{64}$/.test(entry.actor) ? "Unlinked key" : entry.actor;
|
||||
}
|
||||
|
||||
function memoryDetails(entry: Entry) {
|
||||
const details = {
|
||||
"Contributed by": contributor(entry),
|
||||
Evidence: entry.evidence,
|
||||
"When to use": entry.when_to_use,
|
||||
Source: entry.source,
|
||||
Kind: entry.kind,
|
||||
Certainty: entry.certainty,
|
||||
Context: entry.scope,
|
||||
Created: entry.created_at ? new Date(entry.created_at).toLocaleString() : undefined,
|
||||
Updated: new Date(entry.updated_at).toLocaleString(),
|
||||
"Memory ID": entry.memory_id,
|
||||
};
|
||||
return Object.entries(details).filter(([, value]) => !!value);
|
||||
}
|
||||
|
||||
export function MemoryEntriesTable({
|
||||
entries,
|
||||
query,
|
||||
onQueryChange,
|
||||
loading,
|
||||
empty,
|
||||
footer,
|
||||
readOnly,
|
||||
canEdit,
|
||||
busy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: Readonly<{
|
||||
entries: Entry[];
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
loading: boolean;
|
||||
empty: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
readOnly: boolean;
|
||||
canEdit: boolean;
|
||||
busy: boolean;
|
||||
onEdit: (entry: Entry) => void;
|
||||
onDelete: (entry: Entry) => void;
|
||||
}>) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selected = entries.find((entry) => entry.memory_id === selectedId);
|
||||
const columns: ColumnDef<Entry>[] = [
|
||||
{
|
||||
id: "memory",
|
||||
header: "Memory",
|
||||
size: 600,
|
||||
cell: ({ row: { original: entry } }) => (
|
||||
<div className="min-w-48 space-y-1 py-1">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left font-medium text-foreground"
|
||||
aria-label={`Details for ${entry.title}`}
|
||||
onClick={() => setSelectedId(entry.memory_id)}
|
||||
>
|
||||
{entry.title}
|
||||
</Button>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{entry.content}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "contributor",
|
||||
header: "Contributed by",
|
||||
size: 180,
|
||||
cell: ({ row }) => <span className="break-words text-sm">{contributor(row.original)}</span>,
|
||||
},
|
||||
{
|
||||
id: "saved",
|
||||
header: "Updated",
|
||||
size: 130,
|
||||
cell: ({ row }) => (
|
||||
<time dateTime={row.original.updated_at} className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{new Date(row.original.updated_at).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
<span className="block">
|
||||
{new Date(row.original.updated_at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
|
||||
</span>
|
||||
</time>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
header: () => <span className="sr-only">Details</span>,
|
||||
size: 36,
|
||||
cell: () => <ChevronRight className="size-4 text-muted-foreground" aria-hidden="true" />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={entries}
|
||||
columns={columns}
|
||||
getRowId={(entry) => entry.memory_id}
|
||||
onRowClick={(entry) => setSelectedId(entry.memory_id)}
|
||||
isLoading={loading}
|
||||
loadingMessage="Loading memories"
|
||||
noDataMessage={empty}
|
||||
sortingMode="none"
|
||||
paginationMode="none"
|
||||
filterMode="none"
|
||||
toolbar={() => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search memories"
|
||||
placeholder="Search memories"
|
||||
className="h-8 pl-8"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Newest first</span>
|
||||
</div>
|
||||
)}
|
||||
paginationSlot={() => footer}
|
||||
/>
|
||||
<Sheet open={!!selected} onOpenChange={(open) => !open && setSelectedId(null)}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader className="pr-12">
|
||||
<SheetTitle>{selected?.title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
{selected && (
|
||||
<div className="space-y-6 px-4 pb-6">
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{selected.content}</p>
|
||||
<p className="text-xs text-muted-foreground">Contributed by {contributor(selected)}</p>
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger render={<Button variant="ghost" size="sm" className="gap-1" />}>
|
||||
<ChevronDown className="size-3" /> Details
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<dl className="mt-3 grid gap-x-5 gap-y-3 border-t pt-4 text-xs sm:grid-cols-[auto_1fr]">
|
||||
{memoryDetails(selected).map(([label, value]) => (
|
||||
<div key={label} className="contents">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 whitespace-pre-wrap break-words">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2 border-t pt-4">
|
||||
<Button variant="outline" size="sm" disabled={busy || !canEdit} onClick={() => onEdit(selected)}>
|
||||
Edit memory
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onDelete(selected)}>
|
||||
Delete memory
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -55,17 +55,22 @@ export function MemoryPreference({
|
|||
});
|
||||
const canToggle = status.activation === "opt_in" && !!status.scope;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
||||
<Label htmlFor="memory-enabled" className="font-medium">
|
||||
Memory <span aria-hidden="true">{status.active ? "on" : "off"}</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="memory-enabled"
|
||||
aria-label="Memory"
|
||||
checked={status.active}
|
||||
disabled={readOnly || !canToggle || save.isPending}
|
||||
onCheckedChange={(enabled) => save.mutate(enabled)}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Label htmlFor="memory-enabled" className="font-medium">
|
||||
Memory <span aria-hidden="true">{status.active ? "on" : "off"}</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="memory-enabled"
|
||||
aria-label="Memory"
|
||||
checked={status.active}
|
||||
disabled={readOnly || !canToggle || save.isPending}
|
||||
onCheckedChange={(enabled) => save.mutate(enabled)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{status.user_id ? "Applies to this user's keys" : "Applies to this unlinked key"}
|
||||
</p>
|
||||
{save.isPending && (
|
||||
<span role="status" className="sr-only">
|
||||
Updating memory
|
||||
|
|
@ -85,7 +90,7 @@ export function MemoryPolicies({
|
|||
target_type: proxyAdmin ? "gateway" : "team",
|
||||
target_id: proxyAdmin ? "*" : "",
|
||||
activation: "opt_in",
|
||||
scope: "key",
|
||||
scope: proxyAdmin ? "user" : "key",
|
||||
};
|
||||
const [policy, setPolicy] = useState<PolicyInput>(initialPolicy);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
|
@ -94,7 +99,7 @@ export function MemoryPolicies({
|
|||
...policy,
|
||||
target_type: target,
|
||||
target_id: target === "gateway" ? "*" : "",
|
||||
scope: "key",
|
||||
scope: proxyAdmin ? "user" : "key",
|
||||
};
|
||||
setPolicy(selection);
|
||||
setOffset(0);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ let activation = "opt_in";
|
|||
let available = true;
|
||||
let paginated = false;
|
||||
let failPreference = false;
|
||||
let sameUser = false;
|
||||
const entry = {
|
||||
memory_id: "entry-1",
|
||||
key: "demo",
|
||||
|
|
@ -30,6 +31,7 @@ const entry = {
|
|||
created_at: "2026-09-12T00:00:00Z",
|
||||
updated_at: "2026-09-12T00:00:00Z",
|
||||
actor: "u1",
|
||||
actor_name: "Alex Rivera",
|
||||
};
|
||||
const session = (user_role: string) => {
|
||||
const payload = { key: "sk-test", user_id: "u1", user_role, exp: Math.floor(Date.now() / 1000) + 3600 };
|
||||
|
|
@ -45,6 +47,7 @@ beforeEach(async () => {
|
|||
available = true;
|
||||
paginated = false;
|
||||
failPreference = false;
|
||||
sameUser = false;
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const request =
|
||||
|
|
@ -77,11 +80,13 @@ beforeEach(async () => {
|
|||
active: available && (activation === "automatic" || enabled),
|
||||
opted_in: enabled,
|
||||
activation,
|
||||
scope: available ? "key" : null,
|
||||
scope: available ? "user" : null,
|
||||
user_id: keyId === "b".repeat(64) && !sameUser ? "u2" : "u1",
|
||||
user_name: keyId === "b".repeat(64) && !sameUser ? "Jamie Davis" : "Alex Rivera",
|
||||
};
|
||||
if (path === "/v2/memory/entries") {
|
||||
if (request.method === "POST") return entry;
|
||||
if (keyId === "b".repeat(64))
|
||||
if (keyId === "b".repeat(64) && !sameUser)
|
||||
return [{ ...entry, memory_id: "other", title: "Other key memory", content: "Another project" }];
|
||||
if (paginated) {
|
||||
const offset = new URL(request.url).searchParams.get("before_memory_id") === "entry-19" ? 20 : 0;
|
||||
|
|
@ -190,6 +195,20 @@ describe("Memory dashboard", () => {
|
|||
expect(screen.queryByRole("button", { name: "Save memory policy" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the V1 management view available without loading it into automatic memory", async () => {
|
||||
session("proxy_admin");
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Memory />);
|
||||
expect(await screen.findByRole("switch", { name: "Memory" })).not.toBeChecked();
|
||||
expect(calls.filter(({ path }) => path === "/v1/memory")).toEqual([]);
|
||||
await user.click(screen.getByRole("tab", { name: "Memory API (V1)" }));
|
||||
expect(await screen.findByRole("button", { name: "New memory" })).toBeVisible();
|
||||
await waitFor(() => expect(calls.some(({ path }) => path === "/v1/memory")).toBe(true));
|
||||
expect(screen.queryByRole("switch", { name: "Memory" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Automatic memory" }));
|
||||
expect(await screen.findByRole("switch", { name: "Memory" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("allows viewers to inspect memory while disabling preference writes", async () => {
|
||||
session("internal_user_viewer");
|
||||
renderWithProviders(<Memory />);
|
||||
|
|
@ -197,20 +216,49 @@ describe("Memory dashboard", () => {
|
|||
expect(await screen.findByText("Use port 8123")).toBeVisible();
|
||||
});
|
||||
|
||||
it("appends older memories and resets the feed when switching keys", async () => {
|
||||
it("appends older memories and resets the table when switching contexts", async () => {
|
||||
session("proxy_admin");
|
||||
paginated = true;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Memory />);
|
||||
const feed = await screen.findByRole("list", { name: "Saved memories" });
|
||||
await waitFor(() => expect(within(feed).getAllByRole("listitem")).toHaveLength(20));
|
||||
const table = await screen.findByRole("table");
|
||||
await waitFor(() =>
|
||||
expect(within(table).getAllByRole("button", { name: /^Details for Memory \d+$/ })).toHaveLength(20),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Load more memories" }));
|
||||
await waitFor(() => expect(within(feed).getAllByRole("listitem")).toHaveLength(21));
|
||||
expect(screen.getByRole("heading", { name: "Memory 0" })).toBeVisible();
|
||||
await user.click(screen.getByLabelText("Virtual key"));
|
||||
await waitFor(() =>
|
||||
expect(within(table).getAllByRole("button", { name: /^Details for Memory \d+$/ })).toHaveLength(21),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Details for Memory 0" })).toBeVisible();
|
||||
await user.click(screen.getByLabelText("Key context"));
|
||||
await user.click(await screen.findByRole("option", { name: "Other key" }));
|
||||
expect(await screen.findByText("Another project")).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "Memory 0" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Details for Memory 0" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contributors in the readable table and keeps user activation across that user's keys", async () => {
|
||||
session("internal_user");
|
||||
sameUser = true;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Memory />);
|
||||
const table = await screen.findByRole("table");
|
||||
expect(within(table).getByRole("columnheader", { name: "Contributed by" })).toBeVisible();
|
||||
const row = await within(table).findByRole("row", { name: /Demo port/ });
|
||||
expect(within(row).getByText("Use port 8123")).toBeVisible();
|
||||
expect(within(row).getByText("Alex Rivera")).toBeVisible();
|
||||
expect(screen.queryByText("Fixture recommendation")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Applies to this user's keys")).toBeVisible();
|
||||
await user.click(screen.getByRole("switch", { name: "Memory" }));
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).toBeChecked());
|
||||
await user.click(screen.getByLabelText("Key context"));
|
||||
await user.click(await screen.findByRole("option", { name: "Other key" }));
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).toBeChecked());
|
||||
expect(await screen.findByRole("button", { name: "Details for Demo port" })).toBeVisible();
|
||||
await user.click(screen.getByRole("switch", { name: "Memory" }));
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).not.toBeChecked());
|
||||
await user.click(screen.getByLabelText("Key context"));
|
||||
await user.click(await screen.findByRole("option", { name: "QA key" }));
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name: "Memory" })).not.toBeChecked());
|
||||
});
|
||||
|
||||
it("shows administrator-managed memory as on even when the preference is off", async () => {
|
||||
|
|
@ -231,7 +279,7 @@ describe("Memory dashboard", () => {
|
|||
const toggle = await screen.findByRole("switch", { name: "Memory" });
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(toggle).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("Memory is off. Your administrator can make it available for this key.")).toBeVisible();
|
||||
expect(screen.getByText("Memory is off. Your administrator can make it available.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps the actual state off when saving a preference fails", async () => {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import { Button } from "@/components/ui/button";
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { MemoryPolicies } from "./_components/MemorySettings";
|
||||
import { AutomaticMemoryEntries } from "./_components/AutomaticMemoryEntries";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export default function Memory() {
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [view, setView] = useState("v2");
|
||||
const { accessToken, userRole, userId, isViewOnly } = useAuthorized();
|
||||
const canViewMemory = useCan("viewMemory");
|
||||
const teams = useTeams();
|
||||
|
|
@ -29,24 +31,38 @@ export default function Memory() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 px-8 py-8">
|
||||
{userId && <AutomaticMemoryEntries key={userId} userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />}
|
||||
{canManage && userId && (
|
||||
<Collapsible open={advanced} onOpenChange={setAdvanced} className="border-t pt-4">
|
||||
<CollapsibleTrigger render={<Button variant="ghost" className="gap-2 text-muted-foreground" />}>
|
||||
<ChevronDown className={`size-4 transition-transform ${advanced ? "rotate-180" : ""}`} />
|
||||
Advanced settings
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{advanced && (
|
||||
<div className="space-y-6 pt-4">
|
||||
<MemoryPolicies userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />
|
||||
{proxyAdmin && <MemoryView accessToken={accessToken} userID={userId} userRole={userRole} />}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Tabs value={view} onValueChange={(value) => setView(value === "v1" ? "v1" : "v2")} className="gap-6 px-8 py-8">
|
||||
{proxyAdmin && (
|
||||
<TabsList variant="line" aria-label="Memory version">
|
||||
<TabsTrigger value="v2">Automatic memory</TabsTrigger>
|
||||
<TabsTrigger value="v1">Memory API (V1)</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="v2" className="space-y-8">
|
||||
{userId && (
|
||||
<AutomaticMemoryEntries key={userId} userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />
|
||||
)}
|
||||
{canManage && userId && (
|
||||
<Collapsible open={advanced} onOpenChange={setAdvanced} className="border-t pt-4">
|
||||
<CollapsibleTrigger render={<Button variant="ghost" className="gap-2 text-muted-foreground" />}>
|
||||
<ChevronDown className={`size-4 transition-transform ${advanced ? "rotate-180" : ""}`} />
|
||||
Advanced settings
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{advanced && (
|
||||
<div className="space-y-6 pt-4">
|
||||
<MemoryPolicies userId={userId} proxyAdmin={proxyAdmin} readOnly={isViewOnly} />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</TabsContent>
|
||||
{proxyAdmin && (
|
||||
<TabsContent value="v1">
|
||||
{view === "v1" && <MemoryView accessToken={accessToken} userID={userId} userRole={userRole} />}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -31987,6 +31987,8 @@ export interface components {
|
|||
MemoryEntry: {
|
||||
/** Actor */
|
||||
actor?: string | null;
|
||||
/** Actor Name */
|
||||
actor_name?: string | null;
|
||||
/**
|
||||
* Certainty
|
||||
* @default observed
|
||||
|
|
@ -32050,7 +32052,7 @@ export interface components {
|
|||
policy_id: string;
|
||||
/**
|
||||
* Scope
|
||||
* @default key
|
||||
* @default user
|
||||
* @enum {string}
|
||||
*/
|
||||
scope: "key" | "user" | "team" | "project" | "organization";
|
||||
|
|
@ -32078,7 +32080,7 @@ export interface components {
|
|||
activation: "disabled" | "opt_in" | "automatic";
|
||||
/**
|
||||
* Scope
|
||||
* @default key
|
||||
* @default user
|
||||
* @enum {string}
|
||||
*/
|
||||
scope: "key" | "user" | "team" | "project" | "organization";
|
||||
|
|
@ -32110,6 +32112,10 @@ export interface components {
|
|||
policy_id: string | null;
|
||||
/** Scope */
|
||||
scope: ("key" | "user" | "team" | "project" | "organization") | null;
|
||||
/** User Id */
|
||||
user_id?: string | null;
|
||||
/** User Name */
|
||||
user_name?: string | null;
|
||||
};
|
||||
/** MemoryUpdateRequest */
|
||||
MemoryUpdateRequest: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue