diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 0f986e416..166164c50 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -38,14 +38,13 @@ logger = logging.getLogger(__name__) router = APIRouter() +EDITOR_PLATE_MAX_BYTES = 5 * 1024 * 1024 + @router.get("/search-spaces/{search_space_id}/documents/{document_id}/editor-content") async def get_editor_content( search_space_id: int, document_id: int, - max_length: int | None = Query( - None, description="Truncate source_markdown to this many characters" - ), session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): @@ -84,19 +83,16 @@ async def get_editor_content( def _build_response(md: str) -> dict: size_bytes = len(md.encode("utf-8")) - truncated = False - output_md = md - if max_length is not None and size_bytes > max_length: - output_md = md[:max_length] - truncated = True + viewer_mode = "monaco" if size_bytes > EDITOR_PLATE_MAX_BYTES else "plate" return { "document_id": document.id, "title": document.title, "document_type": document.document_type.value, - "source_markdown": output_md, + "source_markdown": md, "content_size_bytes": size_bytes, "chunk_count": chunk_count, - "truncated": truncated, + "viewer_mode": viewer_mode, + "editor_plate_max_bytes": EDITOR_PLATE_MAX_BYTES, "updated_at": document.updated_at.isoformat() if document.updated_at else None, diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 945fbc529..01983cbe1 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -12,7 +12,7 @@ import { XIcon, } from "lucide-react"; import dynamic from "next/dynamic"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { DownloadOriginalButton } from "@/components/documents/download-original-button"; @@ -50,11 +50,13 @@ interface EditorContent { source_markdown: string; content_size_bytes?: number; chunk_count?: number; - truncated?: boolean; + viewer_mode?: ViewerMode; + editor_plate_max_bytes?: number; } const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); type EditorRenderMode = "rich_markdown" | "source_code"; +type ViewerMode = "plate" | "monaco"; type AgentFilesystemMount = { mount: string; @@ -112,6 +114,20 @@ function EditorPanelSkeleton() { ); } +function getUtf8ByteSize(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; + } + if (bytes >= 1024) { + return `${Math.round(bytes / 1024)}KB`; + } + return `${bytes}B`; +} + export function EditorPanelContent({ kind = "document", documentId, @@ -167,7 +183,11 @@ export function EditorPanelContent({ [electronAPI, searchSpaceId] ); - const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD; + const plateMaxBytes = editorDoc?.editor_plate_max_bytes ?? LARGE_DOCUMENT_THRESHOLD; + const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > plateMaxBytes; + const viewerMode: ViewerMode = isMemoryMode + ? "plate" + : (editorDoc?.viewer_mode ?? (isLargeDocument ? "monaco" : "plate")); useEffect(() => { const controller = new AbortController(); @@ -243,8 +263,6 @@ export function EditorPanelContent({ const url = new URL( `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content` ); - url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD)); - const response = await authenticatedFetch(url.toString(), { method: "GET" }); if (controller.signal.aborted) return; @@ -402,7 +420,12 @@ export function EditorPanelContent({ setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev)); setEditedMarkdown(null); if (!options?.silent) { - toast.success("Document saved! Reindexing in background..."); + const savedSizeBytes = getUtf8ByteSize(markdownRef.current); + if (savedSizeBytes > plateMaxBytes) { + toast.success("Document saved. It will reopen in raw markdown mode."); + } else { + toast.success("Document saved! Reindexing in background..."); + } } return true; } catch (err) { @@ -423,6 +446,7 @@ export function EditorPanelContent({ localFilePath, memoryLimits, memoryScope, + plateMaxBytes, resolveLocalVirtualPath, searchSpaceId, ] @@ -432,18 +456,21 @@ export function EditorPanelContent({ ? (isMemoryMode || editorRenderMode === "source_code" || EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) && - !isLargeDocument + viewerMode === "plate" : false; - // Render through PlateEditor for editable doc types (FILE/NOTE). - // Everything else (large docs, non-editable types) falls back to the - // lightweight `MarkdownViewer` — Plate is heavy on multi-MB docs and - // non-editable types don't benefit from its editing UX. + // Render through PlateEditor only when the backend says the rich editor is safe. + // Monaco mode is a raw markdown safety path for large documents. const renderInPlateEditor = isEditableType; const hasUnsavedChanges = editedMarkdown !== null; const showDesktopHeader = !!onClose; const showEditingActions = isEditableType && isEditing; const localFileLanguage = inferMonacoLanguageFromPath(localFilePath); const activeMarkdown = editedMarkdown ?? editorDoc?.source_markdown ?? ""; + const activeMarkdownSizeBytes = useMemo(() => getUtf8ByteSize(activeMarkdown), [activeMarkdown]); + const isNearPlateLimit = activeMarkdownSizeBytes >= plateMaxBytes * 0.9; + const isOverPlateLimit = activeMarkdownSizeBytes > plateMaxBytes; + const showPlateSizeWarning = + showEditingActions && !isMemoryMode && !isLocalFileMode && isNearPlateLimit; const memoryLimitState = isMemoryMode ? getMemoryLimitState(activeMarkdown.length, memoryLimits) : null; @@ -492,14 +519,14 @@ export function EditorPanelContent({ } }, [documentId, editorDoc?.title, searchSpaceId]); - const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && ( - + const largeDocAlert = viewerMode === "monaco" && !isLocalFileMode && editorDoc && ( + This document is too large for the editor ( {Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "} - {editorDoc.chunk_count ?? 0} chunks). Showing a preview below. + {editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below. -
- +
+ {isNearPlateLimit && ( + + + + {isOverPlateLimit + ? `This document is ${formatBytes(activeMarkdownSizeBytes)}, above the rich editor limit of ${formatBytes(plateMaxBytes)}. You can save, but it will reopen in raw markdown mode.` + : `This document is approaching the rich editor limit (${formatBytes(activeMarkdownSizeBytes)} of ${formatBytes(plateMaxBytes)}).`} + + + )} +
+ +
); @@ -265,64 +303,74 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen )} -
-
- {isLargeDocument ? ( - <> - - - - - This document is too large for the editor ( - {Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "} - {doc.chunk_count ?? 0} chunks). Showing a preview below. +
+ {viewerMode === "monaco" ? ( +
+ + + + + This document is too large for the editor ( + {Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "} + {doc.chunk_count ?? 0} chunks). Showing raw markdown below. + + - - + {downloading && } + + + +
+ {}} + /> +
+
+ ) : ( +
+
- - ) : ( - - )} -
+
+
+ )}
); diff --git a/surfsense_web/tests/helpers/api/documents.ts b/surfsense_web/tests/helpers/api/documents.ts index 92055602b..9658feead 100644 --- a/surfsense_web/tests/helpers/api/documents.ts +++ b/surfsense_web/tests/helpers/api/documents.ts @@ -43,7 +43,8 @@ export type EditorContent = { source_markdown: string; content_size_bytes: number; chunk_count: number; - truncated: boolean; + viewer_mode?: "plate" | "monaco"; + editor_plate_max_bytes?: number; }; // Same endpoint the UI hits when a user opens a document in the dashboard.