diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx index 3542f0925..dc002244f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx @@ -1,10 +1,8 @@ "use client"; import { useAtomValue } from "jotai"; -import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pencil } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { ChevronDown, ClipboardCopy, Download, Info } from "lucide-react"; import { toast } from "sonner"; -import { z } from "zod"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { PlateEditor } from "@/components/editor/plate-editor"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -16,102 +14,23 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; - -import { baseApiService } from "@/lib/apis/base-api.service"; - -const MEMORY_HARD_LIMIT = 25_000; - -const MemoryReadSchema = z.object({ - memory_md: z.string(), -}); +import { MEMORY_HARD_LIMIT, useUserMemory } from "@/hooks/use-memory"; export function MemoryContent() { const activeSearchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const [memory, setMemory] = useState(""); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [editQuery, setEditQuery] = useState(""); - const [editing, setEditing] = useState(false); - const [showInput, setShowInput] = useState(false); - const textareaRef = useRef(null); - const inputContainerRef = useRef(null); - - const fetchMemory = useCallback(async () => { - try { - setLoading(true); - const data = await baseApiService.get("/api/v1/users/me/memory", MemoryReadSchema); - setMemory(data.memory_md); - } catch { - toast.error("Failed to load memory"); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchMemory(); - }, [fetchMemory]); - - 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 { memory, displayMemory, loading, saving, reset } = useUserMemory( + Number(activeSearchSpaceId) + ); const handleClear = async () => { try { - setSaving(true); - const data = await baseApiService.put("/api/v1/users/me/memory", MemoryReadSchema, { - body: { memory_md: "" }, - }); - setMemory(data.memory_md); + await reset(); toast.success("Memory cleared"); } catch { toast.error("Failed to clear memory"); - } finally { - setSaving(false); } }; - const handleEdit = async () => { - const query = editQuery.trim(); - if (!query) return; - - try { - setEditing(true); - const data = await baseApiService.post("/api/v1/users/me/memory/edit", MemoryReadSchema, { - body: { query, search_space_id: Number(activeSearchSpaceId) }, - }); - setMemory(data.memory_md); - setEditQuery(""); - setShowInput(false); - toast.success("Memory updated"); - } catch { - toast.error("Failed to edit memory"); - } finally { - setEditing(false); - } - }; - - const openInput = () => { - setShowInput(true); - requestAnimationFrame(() => textareaRef.current?.focus()); - }; - const handleDownload = () => { if (!memory) return; try { @@ -139,14 +58,6 @@ export function MemoryContent() { } }; - const handleKeyDown = (e: React.KeyboardEvent) => { - 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 = () => { @@ -198,54 +109,6 @@ export function MemoryContent() { className="px-5 py-4 text-sm min-h-full" /> - - {showInput ? ( -
-
- setEditQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Tell SurfSense what to remember or forget" - disabled={editing} - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70" - /> - -
-
- ) : ( - - )}
@@ -263,7 +126,7 @@ export function MemoryContent() { size="sm" className="text-xs sm:text-sm" onClick={handleClear} - disabled={saving || editing || !memory} + disabled={saving || !memory} > Reset Memory Reset diff --git a/surfsense_web/components/settings/team-memory-manager.tsx b/surfsense_web/components/settings/team-memory-manager.tsx index 9d3a40e46..6a2cbf52f 100644 --- a/surfsense_web/components/settings/team-memory-manager.tsx +++ b/surfsense_web/components/settings/team-memory-manager.tsx @@ -1,12 +1,7 @@ "use client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtomValue } from "jotai"; -import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pencil } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { ChevronDown, ClipboardCopy, Download, Info } from "lucide-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"; @@ -17,105 +12,24 @@ import { 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(); +import { MEMORY_HARD_LIMIT, useTeamMemory } from "@/hooks/use-memory"; 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(null); - const inputContainerRef = useRef(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 { memory, displayMemory, loading, saving, reset } = useTeamMemory(searchSpaceId); const handleClear = async () => { try { - setSaving(true); - await updateSearchSpace({ - id: searchSpaceId, - data: { shared_memory_md: "" }, - }); + await reset(); 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 { @@ -143,14 +57,6 @@ export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) { } }; - const handleKeyDown = (e: React.KeyboardEvent) => { - 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 = () => { @@ -204,54 +110,6 @@ export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) { className="px-5 py-4 text-sm min-h-full" />
- - {showInput ? ( -
-
- 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" - /> - -
-
- ) : ( - - )}
@@ -269,7 +127,7 @@ export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) { size="sm" className="text-xs sm:text-sm" onClick={handleClear} - disabled={saving || editing || !memory} + disabled={saving || !memory} > Reset Memory Reset diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts index 7449f82b1..08918e2af 100644 --- a/surfsense_web/contracts/types/search-space.types.ts +++ b/surfsense_web/contracts/types/search-space.types.ts @@ -56,7 +56,6 @@ export const updateSearchSpaceRequest = z.object({ description: true, citations_enabled: true, qna_custom_instructions: true, - shared_memory_md: true, ai_file_sort_enabled: true, }) .partial(), diff --git a/surfsense_web/hooks/use-memory.ts b/surfsense_web/hooks/use-memory.ts new file mode 100644 index 000000000..1f7a51790 --- /dev/null +++ b/surfsense_web/hooks/use-memory.ts @@ -0,0 +1,109 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { z } from "zod"; +import { baseApiService } from "@/lib/apis/base-api.service"; + +export const MEMORY_HARD_LIMIT = 25_000; + +const MemoryReadSchema = z.object({ + memory_md: z.string(), +}); + +type MemoryScope = "user" | "team"; + +interface UseMemoryOptions { + scope: MemoryScope; + searchSpaceId?: number | null; + autoLoad?: boolean; +} + +function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) { + if (scope === "user") return "/api/v1/users/me/memory"; + if (!searchSpaceId) throw new Error("searchSpaceId is required for team memory"); + return `/api/v1/searchspaces/${searchSpaceId}/memory`; +} + +export function stripMemoryDisplayPrefixes(memory: string) { + return memory.replace( + /^\s*-\s+(?:\(\d{4}-\d{2}-\d{2}\)\s*\[(?:fact|pref|instr)\]\s*|\d{4}-\d{2}-\d{2}:\s*)/gim, + "- " + ); +} + +export function useMemory({ scope, searchSpaceId, autoLoad = true }: UseMemoryOptions) { + const [memory, setMemory] = useState(""); + const [loading, setLoading] = useState(autoLoad); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await baseApiService.get(getMemoryPath(scope, searchSpaceId), MemoryReadSchema); + setMemory(data.memory_md); + return data.memory_md; + } finally { + setLoading(false); + } + }, [scope, searchSpaceId]); + + useEffect(() => { + if (!autoLoad) return; + load().catch(() => { + setLoading(false); + }); + }, [autoLoad, load]); + + const save = useCallback( + async (memoryMd: string) => { + setSaving(true); + try { + const data = await baseApiService.put( + getMemoryPath(scope, searchSpaceId), + MemoryReadSchema, + { + body: { memory_md: memoryMd }, + } + ); + setMemory(data.memory_md); + return data.memory_md; + } finally { + setSaving(false); + } + }, + [scope, searchSpaceId] + ); + + const reset = useCallback(async () => { + setSaving(true); + try { + const data = await baseApiService.post( + `${getMemoryPath(scope, searchSpaceId)}/reset`, + MemoryReadSchema + ); + setMemory(data.memory_md); + return data.memory_md; + } finally { + setSaving(false); + } + }, [scope, searchSpaceId]); + + return { + memory, + setMemory, + displayMemory: stripMemoryDisplayPrefixes(memory), + loading, + saving, + load, + save, + reset, + }; +} + +export function useUserMemory(searchSpaceId?: number | null) { + return useMemory({ scope: "user", searchSpaceId }); +} + +export function useTeamMemory(searchSpaceId?: number | null) { + return useMemory({ scope: "team", searchSpaceId }); +}