diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index a5b02cbb3..86c9b899a 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -216,13 +216,9 @@ export const DocumentNode = React.memo(function DocumentNode({ return ( <> {isMemoryDocument ? ( - + ) : canMention ? ( ({ detail: "Failed to fetch memory" })); - throw new Error(errorData.detail || "Failed to fetch memory"); - } - const data = (await response.json()) as { - memory_md?: string; - limits?: MemoryLimits; - }; - setMemoryLimits(data.limits ?? null); - const content: EditorContent = { - document_id: memoryScope === "team" ? -1002 : -1001, - title: title || (memoryScope === "team" ? "Team Memory" : "Personal Memory"), - document_type: memoryScope === "team" ? "TEAM_MEMORY" : "USER_MEMORY", - source_markdown: data.memory_md ?? "", - }; + setMemoryLimits(limits); + const content: EditorContent = document; markdownRef.current = content.source_markdown; setDisplayTitle(content.title); setEditorDoc(content); @@ -370,34 +356,14 @@ export function EditorPanelContent({ return true; } if (isMemoryMode) { - if (memoryScope === "team" && !searchSpaceId) { - throw new Error("Missing search space context"); - } - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${ - memoryScope === "team" - ? `/api/v1/searchspaces/${searchSpaceId}/memory` - : "/api/v1/users/me/memory" - }`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ memory_md: markdownRef.current }), - } - ); - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save memory" })); - throw new Error(errorData.detail || "Failed to save memory"); - } - const data = (await response.json()) as { - memory_md?: string; - limits?: MemoryLimits; - }; - const savedContent = data.memory_md ?? markdownRef.current; + if (!memoryScope) throw new Error("Missing memory context"); + const { markdown: savedContent, limits } = await saveMemoryMarkdown({ + scope: memoryScope, + searchSpaceId, + markdown: markdownRef.current, + }); markdownRef.current = savedContent; - setMemoryLimits(data.limits ?? memoryLimits); + setMemoryLimits(limits ?? memoryLimits); setEditorDoc((prev) => (prev ? { ...prev, source_markdown: savedContent } : prev)); setEditedMarkdown(null); if (!options?.silent) { diff --git a/surfsense_web/components/editor-panel/memory.ts b/surfsense_web/components/editor-panel/memory.ts new file mode 100644 index 000000000..aa5b1f68d --- /dev/null +++ b/surfsense_web/components/editor-panel/memory.ts @@ -0,0 +1,116 @@ +"use client"; + +import { authenticatedFetch } from "@/lib/auth-utils"; + +export type MemoryScope = "user" | "team"; + +export interface MemoryLimits { + soft: number; + hard: number; +} + +export type MemoryLimitLevel = "ok" | "warning" | "error"; + +export interface MemoryEditorDocument { + document_id: number; + title: string; + document_type: "USER_MEMORY" | "TEAM_MEMORY"; + source_markdown: string; +} + +interface MemoryReadResponse { + memory_md?: string; + limits?: MemoryLimits; +} + +function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) { + if (scope === "user") return "/api/v1/users/me/memory"; + if (!searchSpaceId) throw new Error("Missing search space context"); + return `/api/v1/searchspaces/${searchSpaceId}/memory`; +} + +function getBackendUrl(path: string) { + return `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${path}`; +} + +export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) { + if (!limits) { + return { + level: "ok" as MemoryLimitLevel, + label: `${length.toLocaleString()} chars`, + isOverLimit: false, + }; + } + + const isOverLimit = length > limits.hard; + const isNearLimit = length > limits.soft; + const level: MemoryLimitLevel = isOverLimit ? "error" : isNearLimit ? "warning" : "ok"; + const suffix = isOverLimit ? " - Exceeds limit" : isNearLimit ? " - Approaching limit" : ""; + + return { + level, + label: `${length.toLocaleString()}/${limits.hard.toLocaleString()} chars${suffix}`, + isOverLimit, + }; +} + +export async function fetchMemoryEditorDocument({ + scope, + searchSpaceId, + title, + signal, +}: { + scope: MemoryScope; + searchSpaceId?: number | null; + title?: string | null; + signal?: AbortSignal; +}) { + const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), { + method: "GET", + signal, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: "Failed to fetch memory" })); + throw new Error(errorData.detail || "Failed to fetch memory"); + } + + const data = (await response.json()) as MemoryReadResponse; + const isTeamMemory = scope === "team"; + + return { + limits: data.limits ?? null, + document: { + document_id: isTeamMemory ? -1002 : -1001, + title: title || (isTeamMemory ? "Team Memory" : "Personal Memory"), + document_type: isTeamMemory ? "TEAM_MEMORY" : "USER_MEMORY", + source_markdown: data.memory_md ?? "", + } satisfies MemoryEditorDocument, + }; +} + +export async function saveMemoryMarkdown({ + scope, + searchSpaceId, + markdown, +}: { + scope: MemoryScope; + searchSpaceId?: number | null; + markdown: string; +}) { + const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ memory_md: markdown }), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: "Failed to save memory" })); + throw new Error(errorData.detail || "Failed to save memory"); + } + + const data = (await response.json()) as MemoryReadResponse; + + return { + markdown: data.memory_md ?? markdown, + limits: data.limits, + }; +} diff --git a/surfsense_web/hooks/use-memory.ts b/surfsense_web/hooks/use-memory.ts deleted file mode 100644 index 609aad537..000000000 --- a/surfsense_web/hooks/use-memory.ts +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { z } from "zod"; -import { baseApiService } from "@/lib/apis/base-api.service"; - -const MemoryLimitsSchema = z.object({ - soft: z.number(), - hard: z.number(), -}); - -const MemoryReadSchema = z.object({ - memory_md: z.string(), - limits: MemoryLimitsSchema, -}); - -type MemoryScope = "user" | "team"; -export type MemoryLimits = z.infer; -export type MemoryLimitLevel = "ok" | "warning" | "error"; - -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 getMemoryLimitState(length: number, limits?: MemoryLimits | null) { - if (!limits) { - return { - level: "ok" as MemoryLimitLevel, - label: `${length.toLocaleString()} chars`, - isOverLimit: false, - }; - } - - const isOverLimit = length > limits.hard; - const isNearLimit = length > limits.soft; - const level: MemoryLimitLevel = isOverLimit ? "error" : isNearLimit ? "warning" : "ok"; - const suffix = isOverLimit ? " - Exceeds limit" : isNearLimit ? " - Approaching limit" : ""; - - return { - level, - label: `${length.toLocaleString()}/${limits.hard.toLocaleString()} chars${suffix}`, - isOverLimit, - }; -} - -export function useMemory({ scope, searchSpaceId, autoLoad = true }: UseMemoryOptions) { - const [memory, setMemory] = useState(""); - const [limits, setLimits] = useState(null); - 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); - setLimits(data.limits); - 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); - setLimits(data.limits); - 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); - setLimits(data.limits); - return data.memory_md; - } finally { - setSaving(false); - } - }, [scope, searchSpaceId]); - - return { - memory, - setMemory, - limits, - 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 }); -}