mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
- Added the ensure_publication function to create and verify the zero_publication if it is missing, ensuring idempotency during database initialization. - Integrated ensure_publication into the create_db_and_tables function to prevent zero-cache crash loops on startup. - Introduced a self-check script to validate the ensure_publication functionality on a create_all-bootstrapped database. - Updated various components to reflect the transition from search space to workspace, including adjustments in imports and routing paths.
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Download, FileQuestionMark, FileText, Pencil, RefreshCw } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { PlateEditor } from "@/components/editor/plate-editor";
|
|
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
|
|
import { MarkdownViewer } from "@/components/markdown-viewer";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { authenticatedFetch } from "@/lib/auth-fetch";
|
|
import { buildBackendUrl } from "@/lib/env-config";
|
|
|
|
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
|
|
|
interface DocumentContent {
|
|
document_id: number;
|
|
title: string;
|
|
document_type?: string;
|
|
source_markdown: string;
|
|
content_size_bytes?: number;
|
|
chunk_count?: number;
|
|
viewer_mode?: ViewerMode;
|
|
editor_plate_max_bytes?: number;
|
|
}
|
|
|
|
function DocumentSkeleton() {
|
|
return (
|
|
<div className="space-y-6 p-8 max-w-4xl mx-auto">
|
|
<div className="h-8 w-3/4 rounded-md bg-muted/60 animate-pulse" />
|
|
<div className="space-y-3">
|
|
<div className="h-4 w-full rounded-md bg-muted/60 animate-pulse" />
|
|
<div className="h-4 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
|
<div className="h-4 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
|
<div className="h-4 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" />
|
|
</div>
|
|
<div className="h-6 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" />
|
|
<div className="space-y-3">
|
|
<div className="h-4 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" />
|
|
<div className="h-4 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" />
|
|
<div className="h-4 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DocumentTabContentProps {
|
|
documentId: number;
|
|
searchSpaceId: number;
|
|
title?: string;
|
|
}
|
|
|
|
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
|
type ViewerMode = "plate" | "monaco";
|
|
|
|
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 DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) {
|
|
const [doc, setDoc] = useState<DocumentContent | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [downloading, setDownloading] = useState(false);
|
|
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
|
const markdownRef = useRef<string>("");
|
|
const initialLoadDone = useRef(false);
|
|
const changeCountRef = useRef(0);
|
|
const router = useRouter();
|
|
|
|
const plateMaxBytes = doc?.editor_plate_max_bytes ?? LARGE_DOCUMENT_THRESHOLD;
|
|
const isLargeDocument = (doc?.content_size_bytes ?? 0) > plateMaxBytes;
|
|
const viewerMode: ViewerMode = doc?.viewer_mode ?? (isLargeDocument ? "monaco" : "plate");
|
|
const activeMarkdown = editedMarkdown ?? doc?.source_markdown ?? "";
|
|
const activeMarkdownSizeBytes = useMemo(() => getUtf8ByteSize(activeMarkdown), [activeMarkdown]);
|
|
const isNearPlateLimit = activeMarkdownSizeBytes >= plateMaxBytes * 0.9;
|
|
const isOverPlateLimit = activeMarkdownSizeBytes > plateMaxBytes;
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setDoc(null);
|
|
setIsEditing(false);
|
|
setEditedMarkdown(null);
|
|
initialLoadDone.current = false;
|
|
changeCountRef.current = 0;
|
|
|
|
const doFetch = async () => {
|
|
try {
|
|
const response = await authenticatedFetch(
|
|
buildBackendUrl(
|
|
`/api/v1/workspaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
|
),
|
|
{ method: "GET" }
|
|
);
|
|
|
|
if (controller.signal.aborted) return;
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response
|
|
.json()
|
|
.catch(() => ({ detail: "Failed to fetch document" }));
|
|
throw new Error(errorData.detail || "Failed to fetch document");
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.source_markdown === undefined || data.source_markdown === null) {
|
|
setError("This document does not have viewable content.");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
markdownRef.current = data.source_markdown;
|
|
setDoc(data);
|
|
initialLoadDone.current = true;
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return;
|
|
console.error("Error fetching document:", err);
|
|
setError(err instanceof Error ? err.message : "Failed to fetch document");
|
|
} finally {
|
|
if (!controller.signal.aborted) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
doFetch().catch(() => {});
|
|
return () => controller.abort();
|
|
}, [documentId, searchSpaceId]);
|
|
|
|
const handleMarkdownChange = useCallback((md: string) => {
|
|
markdownRef.current = md;
|
|
if (!initialLoadDone.current) return;
|
|
changeCountRef.current += 1;
|
|
if (changeCountRef.current <= 1) return;
|
|
setEditedMarkdown(md);
|
|
}, []);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true);
|
|
try {
|
|
const response = await authenticatedFetch(
|
|
buildBackendUrl(`/api/v1/workspaces/${searchSpaceId}/documents/${documentId}/save`),
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ source_markdown: markdownRef.current }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response
|
|
.json()
|
|
.catch(() => ({ detail: "Failed to save document" }));
|
|
throw new Error(errorData.detail || "Failed to save document");
|
|
}
|
|
|
|
setDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
|
|
setEditedMarkdown(null);
|
|
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...");
|
|
}
|
|
} catch (err) {
|
|
console.error("Error saving document:", err);
|
|
toast.error(err instanceof Error ? err.message : "Failed to save document");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [documentId, plateMaxBytes, searchSpaceId]);
|
|
|
|
if (isLoading) return <DocumentSkeleton />;
|
|
|
|
if (error || !doc) {
|
|
const isProcessing = error?.toLowerCase().includes("still being processed");
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
|
<div className="rounded-full bg-muted/50 p-4">
|
|
{isProcessing ? (
|
|
<RefreshCw className="size-8 text-muted-foreground animate-spin" />
|
|
) : (
|
|
<FileQuestionMark className="size-8 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="space-y-1.5 max-w-sm">
|
|
<p className="font-semibold text-foreground text-lg">
|
|
{isProcessing ? "Document is processing" : "Document unavailable"}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">{error || "An unknown error occurred"}</p>
|
|
</div>
|
|
{!isProcessing && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-1 gap-1.5"
|
|
onClick={() => router.refresh()}
|
|
>
|
|
<RefreshCw className="size-3.5" />
|
|
Retry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isEditable = viewerMode === "plate" && EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "");
|
|
|
|
if (isEditing && viewerMode === "plate") {
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-base font-semibold truncate">{doc.title || title || "Untitled"}</h1>
|
|
{editedMarkdown !== null && (
|
|
<p className="text-xs text-muted-foreground">Unsaved changes</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
setEditedMarkdown(null);
|
|
changeCountRef.current = 0;
|
|
}}
|
|
>
|
|
Done editing
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
|
{isNearPlateLimit && (
|
|
<Alert className="m-4 mb-0 shrink-0">
|
|
<FileText className="size-4" />
|
|
<AlertDescription>
|
|
{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)}).`}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
<PlateEditor
|
|
key={`edit-${documentId}`}
|
|
preset="full"
|
|
markdown={doc.source_markdown}
|
|
onMarkdownChange={handleMarkdownChange}
|
|
readOnly={false}
|
|
placeholder="Start writing..."
|
|
editorVariant="default"
|
|
onSave={handleSave}
|
|
hasUnsavedChanges={editedMarkdown !== null}
|
|
isSaving={saving}
|
|
defaultEditing={true}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
|
|
<h1 className="text-base font-semibold truncate flex-1 min-w-0">
|
|
{doc.title || title || "Untitled"}
|
|
</h1>
|
|
{isEditable && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsEditing(true)}
|
|
className="gap-1.5"
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
{viewerMode === "monaco" ? (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<Alert className="m-4 shrink-0">
|
|
<FileText className="size-4" />
|
|
<AlertDescription className="flex items-center justify-between gap-4">
|
|
<span>
|
|
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.
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="relative shrink-0"
|
|
disabled={downloading}
|
|
onClick={async () => {
|
|
setDownloading(true);
|
|
try {
|
|
const response = await authenticatedFetch(
|
|
buildBackendUrl(
|
|
`/api/v1/workspaces/${searchSpaceId}/documents/${documentId}/download-markdown`
|
|
),
|
|
{ method: "GET" }
|
|
);
|
|
if (!response.ok) throw new Error("Download failed");
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
const disposition = response.headers.get("content-disposition");
|
|
const match = disposition?.match(/filename="(.+)"/);
|
|
a.download = match?.[1] ?? `${doc.title || "document"}.md`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
toast.success("Download started");
|
|
} catch {
|
|
toast.error("Failed to download document");
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
}}
|
|
>
|
|
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
|
<Download className="size-3.5" />
|
|
Download .md
|
|
</span>
|
|
{downloading && <Spinner size="sm" className="absolute" />}
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
<SourceCodeEditor
|
|
path={`${doc.title || "document"}.md`}
|
|
language="markdown"
|
|
value={doc.source_markdown}
|
|
readOnly
|
|
onChange={() => {}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-full overflow-auto">
|
|
<div className="max-w-4xl mx-auto px-6 py-6">
|
|
<MarkdownViewer content={doc.source_markdown} enableCitations />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|