mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-02 22:01:05 +02:00
introduced blocknote editor
This commit is contained in:
parent
70f3381d7e
commit
e68286f22e
23 changed files with 2158 additions and 14 deletions
|
|
@ -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')
|
||||||
|
|
@ -20,7 +20,7 @@ from sqlalchemy import (
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
text,
|
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.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, relationship
|
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)
|
content_hash = Column(String, nullable=False, index=True, unique=True)
|
||||||
unique_identifier_hash = Column(String, nullable=True, index=True, unique=True)
|
unique_identifier_hash = Column(String, nullable=True, index=True, unique=True)
|
||||||
embedding = Column(Vector(config.embedding_model_instance.dimension))
|
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(
|
search_space_id = Column(
|
||||||
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from .airtable_add_connector_route import (
|
||||||
)
|
)
|
||||||
from .chats_routes import router as chats_router
|
from .chats_routes import router as chats_router
|
||||||
from .documents_routes import router as documents_router
|
from .documents_routes import router as documents_router
|
||||||
|
from .editor_routes import router as editor_router
|
||||||
from .google_calendar_add_connector_route import (
|
from .google_calendar_add_connector_route import (
|
||||||
router as google_calendar_add_connector_router,
|
router as google_calendar_add_connector_router,
|
||||||
)
|
)
|
||||||
|
|
@ -21,6 +22,7 @@ from .search_spaces_routes import router as search_spaces_router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
router.include_router(search_spaces_router)
|
router.include_router(search_spaces_router)
|
||||||
|
router.include_router(editor_router)
|
||||||
router.include_router(documents_router)
|
router.include_router(documents_router)
|
||||||
router.include_router(podcasts_router)
|
router.include_router(podcasts_router)
|
||||||
router.include_router(chats_router)
|
router.include_router(chats_router)
|
||||||
|
|
|
||||||
161
surfsense_backend/app/routes/editor_routes.py
Normal file
161
surfsense_backend/app/routes/editor_routes.py
Normal 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,
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
@ -144,6 +144,16 @@ async def add_extension_received_document(
|
||||||
|
|
||||||
# Process chunks
|
# Process chunks
|
||||||
chunks = await create_document_chunks(content.pageContent)
|
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
|
# Update or create document
|
||||||
if existing_document:
|
if existing_document:
|
||||||
|
|
@ -154,6 +164,7 @@ async def add_extension_received_document(
|
||||||
existing_document.embedding = summary_embedding
|
existing_document.embedding = summary_embedding
|
||||||
existing_document.document_metadata = content.metadata.model_dump()
|
existing_document.document_metadata = content.metadata.model_dump()
|
||||||
existing_document.chunks = chunks
|
existing_document.chunks = chunks
|
||||||
|
existing_document.blocknote_document = blocknote_json
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_document)
|
await session.refresh(existing_document)
|
||||||
|
|
@ -170,6 +181,7 @@ async def add_extension_received_document(
|
||||||
chunks=chunks,
|
chunks=chunks,
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
blocknote_document=blocknote_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(document)
|
session.add(document)
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,14 @@ async def add_received_file_document_using_unstructured(
|
||||||
|
|
||||||
# Process chunks
|
# Process chunks
|
||||||
chunks = await create_document_chunks(file_in_markdown)
|
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
|
# Update or create document
|
||||||
if existing_document:
|
if existing_document:
|
||||||
|
|
@ -112,6 +120,7 @@ async def add_received_file_document_using_unstructured(
|
||||||
"ETL_SERVICE": "UNSTRUCTURED",
|
"ETL_SERVICE": "UNSTRUCTURED",
|
||||||
}
|
}
|
||||||
existing_document.chunks = chunks
|
existing_document.chunks = chunks
|
||||||
|
existing_document.blocknote_document = blocknote_json
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_document)
|
await session.refresh(existing_document)
|
||||||
|
|
@ -131,6 +140,7 @@ async def add_received_file_document_using_unstructured(
|
||||||
chunks=chunks,
|
chunks=chunks,
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
blocknote_document=blocknote_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(document)
|
session.add(document)
|
||||||
|
|
@ -213,6 +223,14 @@ async def add_received_file_document_using_llamacloud(
|
||||||
|
|
||||||
# Process chunks
|
# Process chunks
|
||||||
chunks = await create_document_chunks(file_in_markdown)
|
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
|
# Update or create document
|
||||||
if existing_document:
|
if existing_document:
|
||||||
|
|
@ -226,6 +244,7 @@ async def add_received_file_document_using_llamacloud(
|
||||||
"ETL_SERVICE": "LLAMACLOUD",
|
"ETL_SERVICE": "LLAMACLOUD",
|
||||||
}
|
}
|
||||||
existing_document.chunks = chunks
|
existing_document.chunks = chunks
|
||||||
|
existing_document.blocknote_document = blocknote_json
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_document)
|
await session.refresh(existing_document)
|
||||||
|
|
@ -245,6 +264,7 @@ async def add_received_file_document_using_llamacloud(
|
||||||
chunks=chunks,
|
chunks=chunks,
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
blocknote_document=blocknote_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(document)
|
session.add(document)
|
||||||
|
|
@ -352,6 +372,14 @@ async def add_received_file_document_using_docling(
|
||||||
|
|
||||||
# Process chunks
|
# Process chunks
|
||||||
chunks = await create_document_chunks(file_in_markdown)
|
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
|
# Update or create document
|
||||||
if existing_document:
|
if existing_document:
|
||||||
|
|
@ -365,6 +393,7 @@ async def add_received_file_document_using_docling(
|
||||||
"ETL_SERVICE": "DOCLING",
|
"ETL_SERVICE": "DOCLING",
|
||||||
}
|
}
|
||||||
existing_document.chunks = chunks
|
existing_document.chunks = chunks
|
||||||
|
existing_document.blocknote_document = blocknote_json
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_document)
|
await session.refresh(existing_document)
|
||||||
|
|
@ -384,6 +413,7 @@ async def add_received_file_document_using_docling(
|
||||||
chunks=chunks,
|
chunks=chunks,
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
blocknote_document=blocknote_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(document)
|
session.add(document)
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,14 @@ async def add_received_markdown_file_document(
|
||||||
|
|
||||||
# Process chunks
|
# Process chunks
|
||||||
chunks = await create_document_chunks(file_in_markdown)
|
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
|
# Update or create document
|
||||||
if existing_document:
|
if existing_document:
|
||||||
|
|
@ -121,6 +129,7 @@ async def add_received_markdown_file_document(
|
||||||
"FILE_NAME": file_name,
|
"FILE_NAME": file_name,
|
||||||
}
|
}
|
||||||
existing_document.chunks = chunks
|
existing_document.chunks = chunks
|
||||||
|
existing_document.blocknote_document = blocknote_json
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_document)
|
await session.refresh(existing_document)
|
||||||
|
|
@ -139,6 +148,7 @@ async def add_received_markdown_file_document(
|
||||||
chunks=chunks,
|
chunks=chunks,
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
blocknote_document=blocknote_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(document)
|
session.add(document)
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,16 @@ async def add_crawled_url_document(
|
||||||
f"Processing content chunks for URL: {url}",
|
f"Processing content chunks for URL: {url}",
|
||||||
{"stage": "chunk_processing"},
|
{"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)
|
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.embedding = summary_embedding
|
||||||
existing_document.document_metadata = url_crawled[0].metadata
|
existing_document.document_metadata = url_crawled[0].metadata
|
||||||
existing_document.chunks = chunks
|
existing_document.chunks = chunks
|
||||||
|
existing_document.blocknote_document = blocknote_json
|
||||||
|
|
||||||
document = existing_document
|
document = existing_document
|
||||||
else:
|
else:
|
||||||
|
|
@ -289,6 +300,7 @@ async def add_crawled_url_document(
|
||||||
chunks=chunks,
|
chunks=chunks,
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
blocknote_document=blocknote_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(document)
|
session.add(document)
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,16 @@ async def add_youtube_video_document(
|
||||||
f"Processing content chunks for video: {video_data.get('title', 'YouTube Video')}",
|
f"Processing content chunks for video: {video_data.get('title', 'YouTube Video')}",
|
||||||
{"stage": "chunk_processing"},
|
{"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)
|
chunks = await create_document_chunks(combined_document_string)
|
||||||
|
|
||||||
|
|
@ -314,6 +324,7 @@ async def add_youtube_video_document(
|
||||||
"thumbnail": video_data.get("thumbnail_url", ""),
|
"thumbnail": video_data.get("thumbnail_url", ""),
|
||||||
}
|
}
|
||||||
existing_document.chunks = chunks
|
existing_document.chunks = chunks
|
||||||
|
existing_document.blocknote_document = blocknote_json
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_document)
|
await session.refresh(existing_document)
|
||||||
|
|
@ -342,6 +353,7 @@ async def add_youtube_video_document(
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
blocknote_document=blocknote_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(document)
|
session.add(document)
|
||||||
|
|
|
||||||
113
surfsense_backend/app/utils/blocknote_converter.py
Normal file
113
surfsense_backend/app/utils/blocknote_converter.py
Normal 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
|
||||||
|
|
||||||
43
surfsense_web/app/api/convert-to-blocknote/route.ts
Normal file
43
surfsense_web/app/api/convert-to-blocknote/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
surfsense_web/app/api/convert-to-markdown/route.ts
Normal file
31
surfsense_web/app/api/convert-to-markdown/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -309,6 +309,7 @@ export function DocumentsTableShell({
|
||||||
refreshDocuments={async () => {
|
refreshDocuments={async () => {
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
}}
|
}}
|
||||||
|
searchSpaceId={searchSpaceId as string}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
|
|
@ -340,6 +341,7 @@ export function DocumentsTableShell({
|
||||||
refreshDocuments={async () => {
|
refreshDocuments={async () => {
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
}}
|
}}
|
||||||
|
searchSpaceId={searchSpaceId as string}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||||
|
|
@ -28,13 +29,16 @@ export function RowActions({
|
||||||
document,
|
document,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
refreshDocuments,
|
refreshDocuments,
|
||||||
|
searchSpaceId,
|
||||||
}: {
|
}: {
|
||||||
document: Document;
|
document: Document;
|
||||||
deleteDocument: (id: number) => Promise<boolean>;
|
deleteDocument: (id: number) => Promise<boolean>;
|
||||||
refreshDocuments: () => Promise<void>;
|
refreshDocuments: () => Promise<void>;
|
||||||
|
searchSpaceId: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
@ -52,6 +56,10 @@ export function RowActions({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -62,11 +70,17 @@ export function RowActions({
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleEdit}>
|
||||||
|
<Pencil className="mr-0 h-4 w-4" />
|
||||||
|
Edit Document
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<JsonMetadataViewer
|
<JsonMetadataViewer
|
||||||
title={document.title}
|
title={document.title}
|
||||||
metadata={document.document_metadata}
|
metadata={document.document_metadata}
|
||||||
trigger={
|
trigger={
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
<FileText className="mr-0 h-4 w-4" />
|
||||||
View Metadata
|
View Metadata
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +95,7 @@ export function RowActions({
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Trash2 className="mr-0 h-4 w-4 text-destructive" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
surfsense_web/components/BlockNoteEditor.tsx
Normal file
53
surfsense_web/components/BlockNoteEditor.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
9
surfsense_web/components/DynamicBlockNoteEditor.tsx
Normal file
9
surfsense_web/components/DynamicBlockNoteEditor.tsx
Normal 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 }
|
||||||
|
);
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
|
|
@ -34,6 +34,42 @@ export function DashboardBreadcrumb() {
|
||||||
autoFetch: !!searchSpaceId,
|
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
|
// Parse the pathname to create breadcrumb items
|
||||||
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
||||||
const segments = path.split("/").filter(Boolean);
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
|
@ -66,6 +102,7 @@ export function DashboardBreadcrumb() {
|
||||||
logs: t("logs"),
|
logs: t("logs"),
|
||||||
chats: t("chats"),
|
chats: t("chats"),
|
||||||
settings: t("settings"),
|
settings: t("settings"),
|
||||||
|
editor: t("editor"),
|
||||||
};
|
};
|
||||||
|
|
||||||
sectionLabel = sectionLabels[section] || sectionLabel;
|
sectionLabel = sectionLabels[section] || sectionLabel;
|
||||||
|
|
@ -73,7 +110,21 @@ export function DashboardBreadcrumb() {
|
||||||
// Handle sub-sections
|
// Handle sub-sections
|
||||||
if (segments[3]) {
|
if (segments[3]) {
|
||||||
const subSection = 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
|
// Handle sources sub-sections
|
||||||
if (section === "sources") {
|
if (section === "sources") {
|
||||||
|
|
@ -81,7 +132,7 @@ export function DashboardBreadcrumb() {
|
||||||
add: "Add Sources",
|
add: "Add Sources",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceLabel = sourceLabels[subSection] || subSectionLabel;
|
const sourceLabel = sourceLabels[subSection] || subSection;
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: "Sources",
|
label: "Sources",
|
||||||
href: `/dashboard/${segments[1]}/sources`,
|
href: `/dashboard/${segments[1]}/sources`,
|
||||||
|
|
@ -98,7 +149,7 @@ export function DashboardBreadcrumb() {
|
||||||
webpage: t("add_webpages"),
|
webpage: t("add_webpages"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const documentLabel = documentLabels[subSection] || subSectionLabel;
|
const documentLabel = documentLabels[subSection] || subSection;
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: t("documents"),
|
label: t("documents"),
|
||||||
href: `/dashboard/${segments[1]}/documents`,
|
href: `/dashboard/${segments[1]}/documents`,
|
||||||
|
|
@ -158,7 +209,7 @@ export function DashboardBreadcrumb() {
|
||||||
manage: t("manage_connectors"),
|
manage: t("manage_connectors"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectorLabel = connectorLabels[subSection] || subSectionLabel;
|
const connectorLabel = connectorLabels[subSection] || subSection;
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: t("connectors"),
|
label: t("connectors"),
|
||||||
href: `/dashboard/${segments[1]}/connectors`,
|
href: `/dashboard/${segments[1]}/connectors`,
|
||||||
|
|
@ -168,6 +219,7 @@ export function DashboardBreadcrumb() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle other sub-sections
|
// Handle other sub-sections
|
||||||
|
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
|
||||||
const subSectionLabels: Record<string, string> = {
|
const subSectionLabels: Record<string, string> = {
|
||||||
upload: t("upload_documents"),
|
upload: t("upload_documents"),
|
||||||
youtube: t("add_youtube"),
|
youtube: t("add_youtube"),
|
||||||
|
|
|
||||||
|
|
@ -615,6 +615,7 @@
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"connectors": "Connectors",
|
"connectors": "Connectors",
|
||||||
"podcasts": "Podcasts",
|
"podcasts": "Podcasts",
|
||||||
|
"editor": "Editor",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"chats": "Chats",
|
"chats": "Chats",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
|
|
||||||
|
|
@ -615,6 +615,7 @@
|
||||||
"documents": "文档",
|
"documents": "文档",
|
||||||
"connectors": "连接器",
|
"connectors": "连接器",
|
||||||
"podcasts": "播客",
|
"podcasts": "播客",
|
||||||
|
"editor": "编辑器",
|
||||||
"logs": "日志",
|
"logs": "日志",
|
||||||
"chats": "聊天",
|
"chats": "聊天",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// Disable StrictMode for BlockNote compatibility with React 19/Next 15
|
||||||
|
reactStrictMode: false,
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
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
|
// Wrap the config with MDX and next-intl plugins
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^1.2.12",
|
"@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",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@llamaindex/chat-ui": "^0.5.17",
|
"@llamaindex/chat-ui": "^0.5.17",
|
||||||
"@next/third-parties": "^15.5.6",
|
"@next/third-parties": "^15.5.6",
|
||||||
|
|
|
||||||
1316
surfsense_web/pnpm-lock.yaml
generated
1316
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue