Merge pull request #1200 from AnishSarkar22/refactor/persistent-memory

refactor: persistent memory
This commit is contained in:
Rohan Verma 2026-04-10 14:34:16 -07:00 committed by GitHub
commit b96dc49c8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2622 additions and 1415 deletions

View file

@ -190,7 +190,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
? "model"
: "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
available from your administrator.
</p>
</AlertDescription>
</Alert>

View file

@ -10,7 +10,6 @@ import {
FileText,
ImageIcon,
RefreshCw,
Shuffle,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@ -44,7 +43,6 @@ import {
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
const ROLE_DESCRIPTIONS = {
@ -79,8 +77,8 @@ const ROLE_DESCRIPTIONS = {
icon: Eye,
title: "Vision LLM",
description: "Vision-capable model for screenshot analysis and context extraction",
color: "text-amber-600 dark:text-amber-400",
bgColor: "bg-amber-500/10",
color: "text-muted-foreground",
bgColor: "bg-muted",
prefKey: "vision_llm_config_id" as const,
configType: "vision" as const,
},
@ -205,11 +203,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
),
];
const isAssignmentComplete =
allLLMConfigs.some((c) => c.id === assignments.agent_llm_id) &&
allLLMConfigs.some((c) => c.id === assignments.document_summary_llm_id) &&
allImageConfigs.some((c) => c.id === assignments.image_generation_config_id);
const isLoading =
configsLoading ||
preferencesLoading ||
@ -231,7 +224,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
return (
<div className="space-y-5 md:space-y-6">
{/* Header actions */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-start">
<Button
variant="secondary"
size="sm"
@ -239,15 +232,9 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
disabled={isLoading}
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
Refresh
</Button>
{isAssignmentComplete && !isLoading && !hasError && (
<Badge variant="outline" className="text-xs gap-1.5 text-muted-foreground">
<CircleCheck className="h-3 w-3" />
All roles assigned
</Badge>
)}
</div>
{/* Error Alert */}
@ -343,8 +330,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
const isAssigned = !!assignedConfig;
const isAutoMode =
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
return (
<div key={key}>
@ -389,7 +374,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
<SelectTrigger className="w-full h-9 md:h-10 text-xs md:text-sm">
<SelectValue placeholder="Select a configuration" />
</SelectTrigger>
<SelectContent className="max-w-[calc(100vw-2rem)]">
<SelectContent className="max-w-[calc(100vw-2rem)] select-none">
<SelectItem
value="unassigned"
className="text-xs md:text-sm py-1.5 md:py-2"
@ -412,21 +397,9 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
className="text-xs md:text-sm py-1.5 md:py-2"
>
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
{isAuto ? (
<Shuffle className="size-3 md:size-3.5 shrink-0 text-muted-foreground" />
) : (
getProviderIcon(config.provider, {
className: "size-3 md:size-3.5 shrink-0",
})
)}
<span className="truncate text-xs md:text-sm">
{config.name}
</span>
{!isAuto && (
<span className="text-muted-foreground text-[10px] md:text-[11px] truncate">
({config.model_name})
</span>
)}
{isAuto && (
<Badge
variant="secondary"
@ -455,15 +428,9 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
className="text-xs md:text-sm py-1.5 md:py-2"
>
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
{getProviderIcon(config.provider, {
className: "size-3 md:size-3.5 shrink-0",
})}
<span className="truncate text-xs md:text-sm">
{config.name}
</span>
<span className="text-muted-foreground text-[10px] md:text-[11px] truncate">
({config.model_name})
</span>
</div>
</SelectItem>
))}
@ -472,63 +439,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
</SelectContent>
</Select>
</div>
{/* Assigned Config Summary */}
{assignedConfig && (
<div
className={cn(
"rounded-lg p-3 border",
isAutoMode
? "bg-violet-50 dark:bg-violet-900/10 border-violet-200/50 dark:border-violet-800/30"
: "bg-muted/40 border-border/50"
)}
>
{isAutoMode ? (
<div className="flex items-center gap-2">
<Shuffle
className={cn(
"w-3.5 h-3.5 shrink-0 text-violet-600 dark:text-violet-400"
)}
/>
<div className="min-w-0">
<p className="text-xs font-medium text-violet-700 dark:text-violet-300">
Auto Mode
</p>
<p className="text-[10px] text-violet-600/70 dark:text-violet-400/70 mt-0.5">
Routes across all available providers
</p>
</div>
</div>
) : (
<div className="flex items-start gap-2">
<IconComponent className="w-3.5 h-3.5 shrink-0 mt-0.5 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{assignedConfig.name}</span>
{"is_global" in assignedConfig && assignedConfig.is_global && (
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
🌐 Global
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 mt-1">
{getProviderIcon(assignedConfig.provider, {
className: "size-3 shrink-0",
})}
<code className="text-[10px] text-muted-foreground font-mono truncate">
{assignedConfig.model_name}
</code>
</div>
{assignedConfig.api_base && (
<p className="text-[10px] text-muted-foreground/60 mt-1 truncate">
{assignedConfig.api_base}
</p>
)}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>

View file

@ -196,7 +196,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<span className="font-medium">
{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
available from your administrator.
</p>
</AlertDescription>
</Alert>

View file

@ -113,11 +113,11 @@ export function MorePagesContent() {
{isLoading ? (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Skeleton className="h-8 w-8 rounded-full bg-muted" />
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4 bg-muted" />
<Skeleton className="h-4 w-3/4" />
</div>
<Skeleton className="h-8 w-16 bg-muted" />
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
) : (

View file

@ -1,7 +1,17 @@
"use client";
import { useAtom } from "jotai";
import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import {
BookText,
Bot,
Brain,
CircleUser,
Earth,
Eye,
ImageIcon,
ListChecks,
UserKey,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import type React from "react";
@ -59,6 +69,13 @@ const PublicChatSnapshotsManager = dynamic(
})),
{ ssr: false }
);
const TeamMemoryManager = dynamic(
() =>
import("@/components/settings/team-memory-manager").then((m) => ({
default: m.TeamMemoryManager,
})),
{ ssr: false }
);
interface SearchSpaceSettingsDialogProps {
searchSpaceId: number;
@ -69,9 +86,9 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
const [state, setState] = useAtom(searchSpaceSettingsDialogAtom);
const navItems = [
{ value: "general", label: t("nav_general"), icon: <FileText className="h-4 w-4" /> },
{ value: "general", label: t("nav_general"), icon: <CircleUser className="h-4 w-4" /> },
{ value: "roles", label: t("nav_role_assignments"), icon: <ListChecks className="h-4 w-4" /> },
{ value: "models", label: t("nav_agent_configs"), icon: <Bot className="h-4 w-4" /> },
{ value: "roles", label: t("nav_role_assignments"), icon: <Brain className="h-4 w-4" /> },
{
value: "image-models",
label: t("nav_image_models"),
@ -82,13 +99,18 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
label: t("nav_vision_models"),
icon: <Eye className="h-4 w-4" />,
},
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
{ value: "team-roles", label: t("nav_team_roles"), icon: <UserKey className="h-4 w-4" /> },
{
value: "prompts",
label: t("nav_system_instructions"),
icon: <MessageSquare className="h-4 w-4" />,
icon: <BookText className="h-4 w-4" />,
},
{ value: "public-links", label: t("nav_public_links"), icon: <Globe className="h-4 w-4" /> },
{
value: "team-memory",
label: "Team Memory",
icon: <Brain className="h-4 w-4" />,
},
{ value: "public-links", label: t("nav_public_links"), icon: <Earth className="h-4 w-4" /> },
];
const content: Record<string, React.ReactNode> = {
@ -99,6 +121,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,297 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pen } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { PlateEditor } from "@/components/editor/plate-editor";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { baseApiService } from "@/lib/apis/base-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
const MEMORY_HARD_LIMIT = 25_000;
const SearchSpaceSchema = z
.object({
shared_memory_md: z.string().optional().default(""),
})
.passthrough();
interface TeamMemoryManagerProps {
searchSpaceId: number;
}
export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) {
const queryClient = useQueryClient();
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 [saving, setSaving] = useState(false);
const [editQuery, setEditQuery] = useState("");
const [editing, setEditing] = useState(false);
const [showInput, setShowInput] = useState(false);
const textareaRef = useRef<HTMLInputElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const memory = searchSpace?.shared_memory_md || "";
useEffect(() => {
if (!showInput) return;
const handlePointerDownOutside = (event: MouseEvent | TouchEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (inputContainerRef.current?.contains(target)) return;
setShowInput(false);
};
document.addEventListener("mousedown", handlePointerDownOutside);
document.addEventListener("touchstart", handlePointerDownOutside, { passive: true });
return () => {
document.removeEventListener("mousedown", handlePointerDownOutside);
document.removeEventListener("touchstart", handlePointerDownOutside);
};
}, [showInput]);
const handleClear = async () => {
try {
setSaving(true);
await updateSearchSpace({
id: searchSpaceId,
data: { shared_memory_md: "" },
});
toast.success("Team memory cleared");
} catch {
toast.error("Failed to clear team memory");
} finally {
setSaving(false);
}
};
const handleEdit = async () => {
const query = editQuery.trim();
if (!query) return;
try {
setEditing(true);
await baseApiService.post(
`/api/v1/searchspaces/${searchSpaceId}/memory/edit`,
SearchSpaceSchema,
{ body: { query } }
);
setEditQuery("");
setShowInput(false);
await queryClient.invalidateQueries({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
});
toast.success("Team memory updated");
} catch {
toast.error("Failed to edit team memory");
} finally {
setEditing(false);
}
};
const openInput = () => {
setShowInput(true);
requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleDownload = () => {
if (!memory) return;
try {
const blob = new Blob([memory], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "team-memory.md";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
toast.error("Failed to download team memory");
}
};
const handleCopyMarkdown = async () => {
if (!memory) return;
try {
await navigator.clipboard.writeText(memory);
toast.success("Copied to clipboard");
} catch {
toast.error("Failed to copy team memory");
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleEdit();
}
};
const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*\[(fact|pref|instr)\]\s*/g, "");
const charCount = memory.length;
const getCounterColor = () => {
if (charCount > MEMORY_HARD_LIMIT) return "text-red-500";
if (charCount > 15_000) return "text-orange-500";
if (charCount > 10_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>
);
}
if (!memory) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h3 className="text-base font-medium text-foreground">
What does SurfSense remember about your team?
</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Nothing yet. SurfSense picks up on team decisions and conventions as your team chats.
</p>
</div>
);
}
return (
<div className="space-y-4">
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
<p>
SurfSense uses this shared memory to provide team-wide context across all conversations
in this search space.
</p>
</AlertDescription>
</Alert>
<div className="relative h-[380px] rounded-lg border bg-background">
<div className="h-full overflow-y-auto scrollbar-thin">
<PlateEditor
markdown={displayMemory}
readOnly
preset="readonly"
variant="default"
editorVariant="none"
className="px-5 py-4 text-sm min-h-full"
/>
</div>
{showInput ? (
<div className="absolute bottom-3 inset-x-3 z-10">
<div
ref={inputContainerRef}
className="relative flex h-[54px] items-center gap-2 rounded-[9999px] border bg-muted/60 backdrop-blur-sm pl-4 pr-1 shadow-sm"
>
<input
ref={textareaRef}
type="text"
value={editQuery}
onChange={(e) => setEditQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Tell SurfSense what to remember or forget about your team"
disabled={editing}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70"
/>
<Button
type="button"
size="icon"
variant="ghost"
onClick={handleEdit}
disabled={editing || !editQuery.trim()}
className={`h-11 w-11 shrink-0 rounded-full ${
editing ? "" : "bg-muted-foreground/15 hover:bg-muted-foreground/20"
}`}
>
{editing ? (
<Spinner size="sm" />
) : (
<ArrowUp className="!h-5 !w-5 text-foreground" strokeWidth={2.25} />
)}
</Button>
</div>
</div>
) : (
<Button
type="button"
size="icon"
variant="secondary"
onClick={openInput}
className="absolute bottom-3 right-3 z-10 h-[54px] w-[54px] rounded-full border bg-muted/60 backdrop-blur-sm shadow-sm"
>
<Pen className="!h-5 !w-5" />
</Button>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className={`text-xs shrink-0 ${getCounterColor()}`}>
{charCount.toLocaleString()} / {MEMORY_HARD_LIMIT.toLocaleString()}
<span className="hidden sm:inline"> characters</span>
<span className="sm:hidden"> chars</span>
{charCount > 15_000 && charCount <= MEMORY_HARD_LIMIT && " - Approaching limit"}
{charCount > MEMORY_HARD_LIMIT && " - Exceeds limit"}
</span>
<div className="flex items-center gap-1.5 sm:gap-2">
<Button
type="button"
variant="destructive"
size="sm"
className="text-xs sm:text-sm"
onClick={handleClear}
disabled={saving || editing || !memory}
>
<span className="hidden sm:inline">Reset Memory</span>
<span className="sm:hidden">Reset</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="secondary" size="sm" disabled={!memory}>
Export
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleCopyMarkdown}>
<ClipboardCopy className="h-4 w-4 mr-2" />
Copy as Markdown
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
Download as Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
import { Brain, CircleUser, Globe, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
@ -51,6 +51,13 @@ const DesktopContent = dynamic(
),
{ 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");
@ -59,7 +66,7 @@ export function UserSettingsDialog() {
const navItems = useMemo(
() => [
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
{ value: "profile", label: t("profile_nav_label"), icon: <CircleUser className="h-4 w-4" /> },
{
value: "api-key",
label: t("api_key_nav_label"),
@ -75,10 +82,15 @@ export function UserSettingsDialog() {
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
{
value: "memory",
label: "Memory",
icon: <Brain className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
icon: <Receipt className="h-4 w-4" />,
icon: <ReceiptText className="h-4 w-4" />,
},
...(isDesktop
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
@ -101,6 +113,7 @@ export function UserSettingsDialog() {
{state.initialTab === "api-key" && <ApiKeyContent />}
{state.initialTab === "prompts" && <PromptsContent />}
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
{state.initialTab === "memory" && <MemoryContent />}
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
{state.initialTab === "desktop" && <DesktopContent />}
</div>

View file

@ -191,7 +191,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
? "model"
: "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
available from your administrator.
</p>
</AlertDescription>
</Alert>