feat: add MemoryContent and TeamMemoryManager components for user and team memory management

This commit is contained in:
Anish Sarkar 2026-04-09 00:03:41 +05:30
parent e21582f259
commit 3ea9b30046
4 changed files with 326 additions and 10 deletions

View file

@ -0,0 +1,145 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { baseApiService } from "@/lib/apis/base-api.service";
const MEMORY_HARD_LIMIT = 25_000;
const MemoryReadSchema = z.object({
memory_md: z.string(),
});
export function MemoryContent() {
const [memory, setMemory] = useState("");
const [savedMemory, setSavedMemory] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const fetchMemory = useCallback(async () => {
try {
setLoading(true);
const data = await baseApiService.get("/api/v1/users/me/memory", MemoryReadSchema);
setMemory(data.memory_md);
setSavedMemory(data.memory_md);
} catch {
toast.error("Failed to load memory");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchMemory();
}, [fetchMemory]);
const handleSave = async () => {
try {
setSaving(true);
const data = await baseApiService.put("/api/v1/users/me/memory", MemoryReadSchema, {
body: { memory_md: memory },
});
setSavedMemory(data.memory_md);
toast.success("Memory saved");
} catch {
toast.error("Failed to save memory");
} finally {
setSaving(false);
}
};
const handleClear = async () => {
try {
setSaving(true);
const data = await baseApiService.put("/api/v1/users/me/memory", MemoryReadSchema, {
body: { memory_md: "" },
});
setMemory(data.memory_md);
setSavedMemory(data.memory_md);
toast.success("Memory cleared");
} catch {
toast.error("Failed to clear memory");
} finally {
setSaving(false);
}
};
const hasChanges = memory !== savedMemory;
const charCount = memory.length;
const isOverLimit = charCount > MEMORY_HARD_LIMIT;
const getCounterColor = () => {
if (charCount > MEMORY_HARD_LIMIT) return "text-red-500";
if (charCount > 20_000) return "text-orange-500";
if (charCount > 15_000) return "text-yellow-500";
return "text-muted-foreground";
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-6">
<div className="space-y-4">
<div>
<Label htmlFor="user-memory">Personal Memory</Label>
<p className="mt-1 text-xs text-muted-foreground">
This is your personal memory document. The AI assistant reads it at the start of
every conversation and uses it to personalize responses. You can edit it directly
or let the assistant update it during conversations.
</p>
</div>
<Textarea
id="user-memory"
value={memory}
onChange={(e) => setMemory(e.target.value)}
placeholder={"## About me\n- ...\n\n## Preferences\n- ...\n\n## Instructions\n- ..."}
className="min-h-[300px] font-mono text-sm"
/>
<div className="flex items-center justify-between">
<span className={`text-xs ${getCounterColor()}`}>
{charCount.toLocaleString()} / {MEMORY_HARD_LIMIT.toLocaleString()} characters
{charCount > 20_000 && charCount <= MEMORY_HARD_LIMIT && " — Approaching limit"}
{isOverLimit && " — Exceeds limit"}
</span>
</div>
</div>
</div>
<div className="flex justify-between">
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleClear}
disabled={saving || !savedMemory}
>
Clear All
</Button>
<Button
type="button"
variant="outline"
onClick={handleSave}
disabled={saving || !hasChanges || isOverLimit}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving && <Spinner size="sm" className="mr-2" />}
Save
</Button>
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useAtom } from "jotai";
import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { Bot, BookMarked, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { useTranslations } from "next-intl";
import type React from "react";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
@ -40,6 +40,10 @@ const PublicChatSnapshotsManager = dynamic(
() => import("@/components/public-chat-snapshots/public-chat-snapshots-manager").then(m => ({ default: m.PublicChatSnapshotsManager })),
{ ssr: false }
);
const TeamMemoryManager = dynamic(
() => import("@/components/settings/team-memory-manager").then(m => ({ default: m.TeamMemoryManager })),
{ ssr: false }
);
interface SearchSpaceSettingsDialogProps {
searchSpaceId: number;
@ -69,6 +73,11 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
label: t("nav_system_instructions"),
icon: <MessageSquare className="h-4 w-4" />,
},
{
value: "team-memory",
label: "Team Memory",
icon: <BookMarked className="h-4 w-4" />,
},
{ value: "public-links", label: t("nav_public_links"), icon: <Globe className="h-4 w-4" /> },
];
@ -80,6 +89,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
"team-memory": <TeamMemoryManager searchSpaceId={searchSpaceId} />,
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,
};

View file

@ -0,0 +1,151 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
const MEMORY_HARD_LIMIT = 25_000;
interface TeamMemoryManagerProps {
searchSpaceId: number;
}
export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) {
const {
data: searchSpace,
isLoading: loading,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
const [memory, setMemory] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (searchSpace) {
setMemory(searchSpace.shared_memory_md || "");
}
}, [searchSpace?.shared_memory_md]);
const hasChanges =
!!searchSpace &&
(searchSpace.shared_memory_md || "") !== memory;
const handleSave = async () => {
try {
setSaving(true);
await updateSearchSpace({
id: searchSpaceId,
data: { shared_memory_md: memory },
});
toast.success("Team memory saved");
} catch {
toast.error("Failed to save team memory");
} finally {
setSaving(false);
}
};
const handleClear = async () => {
try {
setSaving(true);
await updateSearchSpace({
id: searchSpaceId,
data: { shared_memory_md: "" },
});
setMemory("");
toast.success("Team memory cleared");
} catch {
toast.error("Failed to clear team memory");
} finally {
setSaving(false);
}
};
const charCount = memory.length;
const isOverLimit = charCount > MEMORY_HARD_LIMIT;
const getCounterColor = () => {
if (charCount > MEMORY_HARD_LIMIT) return "text-red-500";
if (charCount > 20_000) return "text-orange-500";
if (charCount > 15_000) return "text-yellow-500";
return "text-muted-foreground";
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-6">
<div className="space-y-4">
<div>
<Label htmlFor="team-memory">Team Memory</Label>
<p className="mt-1 text-xs text-muted-foreground">
This is the shared memory document for this search space. The AI assistant reads
it at the start of every conversation and uses it for team-wide context. You can
edit it directly or let the assistant update it during conversations.
</p>
</div>
<Textarea
id="team-memory"
value={memory}
onChange={(e) => setMemory(e.target.value)}
placeholder={
"## Team decisions\n- ...\n\n## Conventions\n- ...\n\n## Key facts\n- ...\n\n## Current priorities\n- ..."
}
className="min-h-[300px] font-mono text-sm"
/>
<div className="flex items-center justify-between">
<span className={`text-xs ${getCounterColor()}`}>
{charCount.toLocaleString()} / {MEMORY_HARD_LIMIT.toLocaleString()} characters
{charCount > 20_000 && charCount <= MEMORY_HARD_LIMIT && " — Approaching limit"}
{isOverLimit && " — Exceeds limit"}
</span>
</div>
</div>
</div>
<div className="flex justify-between">
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleClear}
disabled={saving || !(searchSpace?.shared_memory_md)}
>
Clear All
</Button>
<Button
type="button"
variant="outline"
onClick={handleSave}
disabled={saving || !hasChanges || isOverLimit}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{saving && <Spinner size="sm" className="mr-2" />}
Save
</Button>
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useAtom } from "jotai";
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
import { Brain, Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
@ -33,6 +33,10 @@ const DesktopContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent").then(m => ({ default: m.DesktopContent })),
{ ssr: false }
);
const MemoryContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/MemoryContent").then(m => ({ default: m.MemoryContent })),
{ ssr: false }
);
export function UserSettingsDialog() {
const t = useTranslations("userSettings");
@ -57,12 +61,17 @@ export function UserSettingsDialog() {
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
icon: <Receipt className="h-4 w-4" />,
},
...(isDesktop
{
value: "memory",
label: "Memory",
icon: <Brain className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
icon: <Receipt className="h-4 w-4" />,
},
...(isDesktop
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
: []),
],
@ -83,8 +92,9 @@ export function UserSettingsDialog() {
{state.initialTab === "api-key" && <ApiKeyContent />}
{state.initialTab === "prompts" && <PromptsContent />}
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
{state.initialTab === "desktop" && <DesktopContent />}
{state.initialTab === "memory" && <MemoryContent />}
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
{state.initialTab === "desktop" && <DesktopContent />}
</div>
</SettingsDialog>
);