introduced blocknote editor

This commit is contained in:
Anish Sarkar 2025-11-23 15:23:31 +05:30
parent 70f3381d7e
commit e68286f22e
23 changed files with 2158 additions and 14 deletions

View file

@ -0,0 +1,41 @@
"""38_add_blocknote_fields_to_documents
Revision ID: 38
Revises: 37
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '38'
down_revision: str | None = '37'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema - Add BlockNote fields only."""
op.add_column(
'documents',
sa.Column('blocknote_document', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
op.add_column(
'documents',
sa.Column('content_needs_reindexing', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
'documents',
sa.Column('last_edited_at', sa.TIMESTAMP(timezone=True), nullable=True)
)
def downgrade() -> None:
"""Downgrade schema - Remove BlockNote fields."""
op.drop_column('documents', 'last_edited_at')
op.drop_column('documents', 'content_needs_reindexing')
op.drop_column('documents', 'blocknote_document')

View file

@ -20,7 +20,7 @@ from sqlalchemy import (
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, relationship
@ -178,6 +178,17 @@ class Document(BaseModel, TimestampMixin):
content_hash = Column(String, nullable=False, index=True, unique=True)
unique_identifier_hash = Column(String, nullable=True, index=True, unique=True)
embedding = Column(Vector(config.embedding_model_instance.dimension))
# BlockNote live editing state (NULL when never edited)
blocknote_document = Column(JSONB, nullable=True)
# blocknote background reindex flag
content_needs_reindexing = Column(
Boolean, nullable=False, default=False, server_default=text("false")
)
# Track when blocknote document was last edited
last_edited_at = Column(TIMESTAMP(timezone=True), nullable=True)
search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False

View file

@ -5,6 +5,7 @@ from .airtable_add_connector_route import (
)
from .chats_routes import router as chats_router
from .documents_routes import router as documents_router
from .editor_routes import router as editor_router
from .google_calendar_add_connector_route import (
router as google_calendar_add_connector_router,
)
@ -21,6 +22,7 @@ from .search_spaces_routes import router as search_spaces_router
router = APIRouter()
router.include_router(search_spaces_router)
router.include_router(editor_router)
router.include_router(documents_router)
router.include_router(podcasts_router)
router.include_router(chats_router)

View file

@ -0,0 +1,161 @@
"""
Editor routes for BlockNote document editing.
"""
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Document, SearchSpace, User, get_async_session
from app.users import current_active_user
from app.utils.blocknote_converter import (
convert_blocknote_to_markdown,
convert_markdown_to_blocknote,
)
router = APIRouter()
@router.get("/documents/{document_id}/editor-content")
async def get_editor_content(
document_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get document content for editing.
Returns BlockNote JSON document. If blocknote_document is NULL,
attempts to convert from `content` - though this won't work well
for old documents that only have summaries.
"""
result = await session.execute(
select(Document)
.join(SearchSpace)
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
)
document = result.scalars().first() # ✅ Changed from scalar_one_or_none()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
# If blocknote_document exists, return it
if document.blocknote_document:
return {
"document_id": document.id,
"title": document.title,
"blocknote_document": document.blocknote_document,
"last_edited_at": document.last_edited_at.isoformat() if document.last_edited_at else None,
}
# For old documents without blocknote_document, return error
# (Can't convert summary back to full document)
raise HTTPException(
status_code=400,
detail="This document was uploaded before editing was enabled. Please re-upload to enable editing."
)
@router.put("/documents/{document_id}/blocknote-content")
async def update_blocknote_content(
document_id: int,
data: dict[str, Any],
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Auto-save BlockNote document during editing.
Only updates blocknote_document field, not content.
"""
result = await session.execute(
select(Document)
.join(SearchSpace)
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
)
document = result.scalars().first() # ✅ Changed from scalar_one_or_none()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
blocknote_document = data.get("blocknote_document")
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
# Update only blocknote_document and last_edited_at
document.blocknote_document = blocknote_document
document.last_edited_at = datetime.now(UTC)
await session.commit()
await session.refresh(document)
return {"status": "saved", "last_edited_at": document.last_edited_at.isoformat()}
# did not implement reindexing (for now)
# @router.post("/documents/{document_id}/finalize-edit")
# async def finalize_edit(
# document_id: int,
# session: AsyncSession = Depends(get_async_session),
# user: User = Depends(current_active_user),
# ):
# """
# Finalize document editing: convert BlockNote to markdown,
# update content (summary), and trigger reindexing.
# """
# result = await session.execute(
# select(Document)
# .join(SearchSpace)
# .filter(Document.id == document_id, SearchSpace.user_id == user.id)
# )
# document = result.scalars().first()
# if not document:
# raise HTTPException(status_code=404, detail="Document not found")
# if not document.blocknote_document:
# raise HTTPException(
# status_code=400,
# detail="Document has no BlockNote content to finalize"
# )
# # 1. Convert BlockNote JSON → Markdown
# full_markdown = await convert_blocknote_to_markdown(document.blocknote_document)
# if not full_markdown:
# raise HTTPException(
# status_code=500,
# detail="Failed to convert BlockNote document to markdown"
# )
# # 2. Generate new summary from full markdown
# from app.services.llm_service import get_user_long_context_llm
# from app.utils.document_converters import generate_document_summary
# user_llm = await get_user_long_context_llm(session, str(user.id), document.search_space_id)
# if not user_llm:
# raise HTTPException(
# status_code=500,
# detail="No LLM configured for summary generation"
# )
# document_metadata = document.document_metadata or {}
# summary_content, summary_embedding = await generate_document_summary(
# full_markdown, user_llm, document_metadata
# )
# # 3. Update document fields
# document.content = summary_content
# document.embedding = summary_embedding
# document.content_needs_reindexing = True # Trigger chunk regeneration
# document.last_edited_at = datetime.now(UTC)
# await session.commit()
# return {
# "status": "finalized",
# "message": "Document saved. Summary and chunks will be regenerated in the background.",
# "content_needs_reindexing": True,
# }

View file

@ -144,6 +144,16 @@ async def add_extension_received_document(
# Process chunks
chunks = await create_document_chunks(content.pageContent)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json:
logging.warning(
f"Failed to convert extension document '{content.metadata.VisitedWebPageTitle}' "
f"to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
@ -154,6 +164,7 @@ async def add_extension_received_document(
existing_document.embedding = summary_embedding
existing_document.document_metadata = content.metadata.model_dump()
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
await session.commit()
await session.refresh(existing_document)
@ -170,6 +181,7 @@ async def add_extension_received_document(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
)
session.add(document)

View file

@ -99,6 +99,14 @@ async def add_received_file_document_using_unstructured(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable")
# Update or create document
if existing_document:
@ -112,6 +120,7 @@ async def add_received_file_document_using_unstructured(
"ETL_SERVICE": "UNSTRUCTURED",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
await session.commit()
await session.refresh(existing_document)
@ -131,6 +140,7 @@ async def add_received_file_document_using_unstructured(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
)
session.add(document)
@ -213,6 +223,14 @@ async def add_received_file_document_using_llamacloud(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable")
# Update or create document
if existing_document:
@ -226,6 +244,7 @@ async def add_received_file_document_using_llamacloud(
"ETL_SERVICE": "LLAMACLOUD",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
await session.commit()
await session.refresh(existing_document)
@ -245,6 +264,7 @@ async def add_received_file_document_using_llamacloud(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
)
session.add(document)
@ -352,6 +372,14 @@ async def add_received_file_document_using_docling(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable")
# Update or create document
if existing_document:
@ -365,6 +393,7 @@ async def add_received_file_document_using_docling(
"ETL_SERVICE": "DOCLING",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
await session.commit()
await session.refresh(existing_document)
@ -384,6 +413,7 @@ async def add_received_file_document_using_docling(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
)
session.add(document)

View file

@ -109,6 +109,14 @@ async def add_received_markdown_file_document(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable")
# Update or create document
if existing_document:
@ -121,6 +129,7 @@ async def add_received_markdown_file_document(
"FILE_NAME": file_name,
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
await session.commit()
await session.refresh(existing_document)
@ -139,6 +148,7 @@ async def add_received_markdown_file_document(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
)
session.add(document)

View file

@ -247,6 +247,16 @@ async def add_crawled_url_document(
f"Processing content chunks for URL: {url}",
{"stage": "chunk_processing"},
)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json:
logging.warning(
f"Failed to convert crawled URL '{url}' to BlockNote JSON, "
"document will not be editable"
)
chunks = await create_document_chunks(content_in_markdown)
@ -267,6 +277,7 @@ async def add_crawled_url_document(
existing_document.embedding = summary_embedding
existing_document.document_metadata = url_crawled[0].metadata
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
document = existing_document
else:
@ -289,6 +300,7 @@ async def add_crawled_url_document(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
)
session.add(document)

View file

@ -290,6 +290,16 @@ async def add_youtube_video_document(
f"Processing content chunks for video: {video_data.get('title', 'YouTube Video')}",
{"stage": "chunk_processing"},
)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert transcript to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json:
logging.warning(
f"Failed to convert YouTube video '{video_id}' to BlockNote JSON, "
"document will not be editable"
)
chunks = await create_document_chunks(combined_document_string)
@ -314,6 +324,7 @@ async def add_youtube_video_document(
"thumbnail": video_data.get("thumbnail_url", ""),
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
await session.commit()
await session.refresh(existing_document)
@ -342,6 +353,7 @@ async def add_youtube_video_document(
search_space_id=search_space_id,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
)
session.add(document)

View file

@ -0,0 +1,113 @@
import logging
from typing import Any
import httpx
from app.config import config
logger = logging.getLogger(__name__)
async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
"""
Convert markdown to BlockNote JSON via Next.js API.
Args:
markdown: Markdown string to convert
Returns:
BlockNote document as dict, or None if conversion fails
"""
if not markdown or not markdown.strip():
logger.warning("Empty markdown provided for conversion")
return None
if not markdown or len(markdown) < 10:
logger.warning("Markdown became too short after sanitization")
# Return a minimal BlockNote document
return [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Document content could not be converted for editing.",
"styles": {}
}
],
"children": []
}
]
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-blocknote",
json={"markdown": markdown},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
blocknote_document = data.get("blocknote_document")
if blocknote_document:
logger.info(f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)")
return blocknote_document
else:
logger.warning("Next.js API returned empty blocknote_document")
return None
except httpx.TimeoutException:
logger.error("Timeout converting markdown to BlockNote after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}")
# Log first 1000 chars of problematic markdown for debugging
logger.debug(f"Problematic markdown sample: {markdown[:1000]}")
return None
except Exception as e:
logger.error(f"Failed to convert markdown to BlockNote: {e}", exc_info=True)
return None
async def convert_blocknote_to_markdown(blocknote_document: dict[str, Any] | list[dict[str, Any]]) -> str | None:
"""
Convert BlockNote JSON to markdown via Next.js API.
Args:
blocknote_document: BlockNote document as dict or list of blocks
Returns:
Markdown string, or None if conversion fails
"""
if not blocknote_document:
logger.warning("Empty BlockNote document provided for conversion")
return None
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-markdown",
json={"blocknote_document": blocknote_document},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
markdown = data.get("markdown")
if markdown:
logger.info(f"Successfully converted BlockNote to markdown ({len(markdown)} chars)")
return markdown
else:
logger.warning("Next.js API returned empty markdown")
return None
except httpx.TimeoutException:
logger.error("Timeout converting BlockNote to markdown after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}")
return None
except Exception as e:
logger.error(f"Failed to convert BlockNote to markdown: {e}", exc_info=True)
return None

View file

@ -0,0 +1,43 @@
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { markdown } = await request.json();
if (!markdown || typeof markdown !== "string") {
return NextResponse.json(
{ error: "Markdown string is required" },
{ status: 400 }
);
}
// Log raw markdown input before conversion
// console.log(`\n${"=".repeat(80)}`);
// console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):");
// console.log("=".repeat(80));
// console.log(markdown);
// console.log(`${"=".repeat(80)}\n`);
// Create server-side editor instance
const editor = ServerBlockNoteEditor.create();
// Convert markdown directly to BlockNote blocks
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
if (!blocks || blocks.length === 0) {
throw new Error("Markdown parsing returned no blocks");
}
return NextResponse.json({ blocknote_document: blocks });
} catch (error: any) {
console.error("Failed to convert markdown to BlockNote:", error);
return NextResponse.json(
{
error: "Failed to convert markdown to BlockNote blocks",
details: error.message
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,31 @@
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { blocknote_document } = await request.json();
if (!blocknote_document || !Array.isArray(blocknote_document)) {
return NextResponse.json(
{ error: "BlockNote document array is required" },
{ status: 400 }
);
}
// Create server-side editor instance
const editor = ServerBlockNoteEditor.create();
// Convert BlockNote blocks to markdown
const markdown = await editor.blocksToMarkdownLossy(blocknote_document);
return NextResponse.json({
markdown
});
} catch (error) {
console.error("Failed to convert BlockNote to markdown:", error);
return NextResponse.json(
{ error: "Failed to convert BlockNote blocks to markdown" },
{ status: 500 }
);
}
}

View file

@ -309,6 +309,7 @@ export function DocumentsTableShell({
refreshDocuments={async () => {
await onRefresh();
}}
searchSpaceId={searchSpaceId as string}
/>
</TableCell>
</motion.tr>
@ -340,6 +341,7 @@ export function DocumentsTableShell({
refreshDocuments={async () => {
await onRefresh();
}}
searchSpaceId={searchSpaceId as string}
/>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">

View file

@ -1,6 +1,7 @@
"use client";
import { MoreHorizontal } from "lucide-react";
import { MoreHorizontal, Pencil, FileText, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
@ -28,13 +29,16 @@ export function RowActions({
document,
deleteDocument,
refreshDocuments,
searchSpaceId,
}: {
document: Document;
deleteDocument: (id: number) => Promise<boolean>;
refreshDocuments: () => Promise<void>;
searchSpaceId: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const handleDelete = async () => {
setIsDeleting(true);
@ -52,6 +56,10 @@ export function RowActions({
}
};
const handleEdit = () => {
router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`);
};
return (
<div className="flex justify-end">
<DropdownMenu>
@ -62,11 +70,17 @@ export function RowActions({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="mr-0 h-4 w-4" />
Edit Document
</DropdownMenuItem>
<DropdownMenuSeparator />
<JsonMetadataViewer
title={document.title}
metadata={document.document_metadata}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<FileText className="mr-0 h-4 w-4" />
View Metadata
</DropdownMenuItem>
}
@ -81,6 +95,7 @@ export function RowActions({
setIsOpen(true);
}}
>
<Trash2 className="mr-0 h-4 w-4 text-destructive" />
Delete
</DropdownMenuItem>
</AlertDialogTrigger>

View file

@ -0,0 +1,209 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
interface EditorContent {
document_id: number;
title: string;
blocknote_document: any;
last_edited_at: string | null;
}
export default function EditorPage() {
const params = useParams();
const router = useRouter();
const documentId = params.documentId as string;
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editorContent, setEditorContent] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
// Get auth token
const token = typeof window !== "undefined"
? localStorage.getItem("surfsense_bearer_token")
: null;
// Fetch document content - DIRECT CALL TO FASTAPI
useEffect(() => {
async function fetchDocument() {
if (!token) {
console.error("No auth token found");
setError("Please login to access the editor");
setLoading(false);
return;
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/editor-content`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
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();
// Check if blocknote_document exists
if (!data.blocknote_document) {
setError("This document does not have BlockNote content. Please re-upload the document to enable editing.");
setLoading(false);
return;
}
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
} catch (error) {
console.error("Error fetching document:", error);
setError(error instanceof Error ? error.message : "Failed to fetch document. Please try again.");
} finally {
setLoading(false);
}
}
if (documentId && token) {
fetchDocument();
}
}, [documentId, token]);
// Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
useEffect(() => {
if (!editorContent || !token) return;
const interval = setInterval(async () => {
try {
await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ blocknote_document: editorContent }),
}
);
console.log("Auto-saved");
} catch (error) {
console.error("Auto-save failed:", error);
}
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [editorContent, documentId, token]);
// Save and exit - DIRECT CALL TO FASTAPI
const handleSave = async () => {
if (!token) {
alert("Please login to save");
return;
}
if (!editorContent) {
alert("No content to save");
return;
}
setSaving(true);
try {
// Save blocknote_document to database (without finalizing/reindexing)
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ blocknote_document: editorContent }),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
// Redirect back to documents list
router.push(`/dashboard/${params.search_space_id}/documents`);
} catch (error) {
console.error("Error saving document:", error);
alert(error instanceof Error ? error.message : "Failed to save document. Please try again.");
} finally {
setSaving(false);
}
};
if (loading) {
return <div>Loading editor...</div>;
}
if (error) {
return (
// <div className="h-screen flex items-center justify-center">
<div className="flex items-center justify-center min-h-[400px]">
<div className="max-w-md p-6 border border-red-300 rounded-lg bg-red-50">
<h2 className="text-xl font-bold text-red-800 mb-2">Error</h2>
<p className="text-red-700 mb-4">{error}</p>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Go Back
</button>
</div>
</div>
);
}
if (!document) {
return <div>Document not found</div>;
}
return (
// <div className="h-screen flex flex-col">
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="border-b p-4 flex justify-between items-center">
<h1 className="text-xl font-bold">{document.title}</h1>
<div className="flex gap-2">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border rounded"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{saving ? "Saving..." : "Save & Exit"}
</button>
</div>
</div>
{/* Editor - Now using dynamic import */}
<div className="flex-1 overflow-auto">
<BlockNoteEditor
initialContent={editorContent}
onChange={setEditorContent}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef } from "react";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
interface BlockNoteEditorProps {
initialContent?: any;
onChange?: (content: any) => void;
}
export default function BlockNoteEditor({
initialContent,
onChange,
}: BlockNoteEditorProps) {
// Track the initial content to prevent re-initialization
const initialContentRef = useRef<any>(null);
const isInitializedRef = useRef(false);
// Creates a new editor instance - only use initialContent on first render
const editor = useCreateBlockNote({
initialContent: initialContentRef.current === null ? (initialContent || undefined) : undefined,
});
// Store initial content on first render only
useEffect(() => {
if (initialContent && initialContentRef.current === null) {
initialContentRef.current = initialContent;
isInitializedRef.current = true;
}
}, [initialContent]);
// Call onChange when document changes (but don't update from props)
useEffect(() => {
if (!onChange || !editor || !isInitializedRef.current) return;
const handleChange = () => {
onChange(editor.document);
};
// Subscribe to document changes
const unsubscribe = editor.onChange(handleChange);
return () => {
unsubscribe();
};
}, [editor, onChange]);
// Renders the editor instance
return <BlockNoteView editor={editor} />;
}

View file

@ -0,0 +1,9 @@
"use client";
import dynamic from "next/dynamic";
// Dynamically import BlockNote editor with SSR disabled
export const BlockNoteEditor = dynamic(
() => import("./BlockNoteEditor"),
{ ssr: false }
);

View file

@ -3,7 +3,7 @@
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import {
Breadcrumb,
@ -34,6 +34,42 @@ export function DashboardBreadcrumb() {
autoFetch: !!searchSpaceId,
});
// State to store document title for editor breadcrumb
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
// Fetch document title when on editor page
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
const token = typeof window !== "undefined"
? localStorage.getItem("surfsense_bearer_token")
: null;
if (token) {
fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/editor-content`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
.then((res) => res.json())
.then((data) => {
if (data.title) {
setDocumentTitle(data.title);
}
})
.catch(() => {
// If fetch fails, just use the document ID
setDocumentTitle(null);
});
}
} else {
setDocumentTitle(null);
}
}, [segments, searchSpaceId]);
// Parse the pathname to create breadcrumb items
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
const segments = path.split("/").filter(Boolean);
@ -66,6 +102,7 @@ export function DashboardBreadcrumb() {
logs: t("logs"),
chats: t("chats"),
settings: t("settings"),
editor: t("editor"),
};
sectionLabel = sectionLabels[section] || sectionLabel;
@ -73,7 +110,21 @@ export function DashboardBreadcrumb() {
// Handle sub-sections
if (segments[3]) {
const subSection = segments[3];
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
// Handle editor sub-sections (document ID)
if (section === "editor") {
const documentLabel = documentTitle || subSection;
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({
label: sectionLabel,
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
return breadcrumbs;
}
// Handle sources sub-sections
if (section === "sources") {
@ -81,7 +132,7 @@ export function DashboardBreadcrumb() {
add: "Add Sources",
};
const sourceLabel = sourceLabels[subSection] || subSectionLabel;
const sourceLabel = sourceLabels[subSection] || subSection;
breadcrumbs.push({
label: "Sources",
href: `/dashboard/${segments[1]}/sources`,
@ -98,7 +149,7 @@ export function DashboardBreadcrumb() {
webpage: t("add_webpages"),
};
const documentLabel = documentLabels[subSection] || subSectionLabel;
const documentLabel = documentLabels[subSection] || subSection;
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
@ -158,7 +209,7 @@ export function DashboardBreadcrumb() {
manage: t("manage_connectors"),
};
const connectorLabel = connectorLabels[subSection] || subSectionLabel;
const connectorLabel = connectorLabels[subSection] || subSection;
breadcrumbs.push({
label: t("connectors"),
href: `/dashboard/${segments[1]}/connectors`,
@ -168,6 +219,7 @@ export function DashboardBreadcrumb() {
}
// Handle other sub-sections
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
const subSectionLabels: Record<string, string> = {
upload: t("upload_documents"),
youtube: t("add_youtube"),

View file

@ -615,6 +615,7 @@
"documents": "Documents",
"connectors": "Connectors",
"podcasts": "Podcasts",
"editor": "Editor",
"logs": "Logs",
"chats": "Chats",
"settings": "Settings",

View file

@ -615,6 +615,7 @@
"documents": "文档",
"connectors": "连接器",
"podcasts": "播客",
"editor": "编辑器",
"logs": "日志",
"chats": "聊天",
"settings": "设置",

View file

@ -7,6 +7,8 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const nextConfig: NextConfig = {
output: "standalone",
// Disable StrictMode for BlockNote compatibility with React 19/Next 15
reactStrictMode: false,
typescript: {
ignoreBuildErrors: true,
},
@ -21,6 +23,22 @@ const nextConfig: NextConfig = {
},
],
},
// Mark BlockNote server packages as external
serverExternalPackages: [
'@blocknote/server-util',
],
// Configure webpack to handle blocknote packages
webpack: (config, { isServer }) => {
if (isServer) {
// Don't bundle these packages on the server
config.externals = [
...(config.externals || []),
'@blocknote/server-util',
];
}
return config;
},
};
// Wrap the config with MDX and next-intl plugins

View file

@ -22,6 +22,10 @@
},
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@blocknote/core": "^0.42.3",
"@blocknote/mantine": "^0.42.3",
"@blocknote/react": "^0.42.3",
"@blocknote/server-util": "^0.42.3",
"@hookform/resolvers": "^4.1.3",
"@llamaindex/chat-ui": "^0.5.17",
"@next/third-parties": "^15.5.6",

File diff suppressed because it is too large Load diff