From be33b8920e60ada6515c4aaa0b868542b31a18a9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 19:52:15 +0200 Subject: [PATCH 01/42] add public_share_token column to NewChatThread --- surfsense_backend/app/db.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 771f956b3..1084319cd 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -358,6 +358,14 @@ class NewChatThread(BaseModel, TimestampMixin): index=True, ) + # Public sharing - cryptographic token for public URL access + public_share_token = Column( + String(64), + nullable=True, + unique=True, + index=True, + ) + # Relationships search_space = relationship("SearchSpace", back_populates="new_chat_threads") created_by = relationship("User", back_populates="new_chat_threads") From d16b086adae565cfd2a53684c59ac7b57cbc09cb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 19:53:02 +0200 Subject: [PATCH 02/42] add public_share_enabled column to NewChatThread --- surfsense_backend/app/db.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 1084319cd..d13e0cbdd 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -365,6 +365,13 @@ class NewChatThread(BaseModel, TimestampMixin): unique=True, index=True, ) + # Whether public sharing is currently enabled for this thread + public_share_enabled = Column( + Boolean, + nullable=False, + default=False, + server_default="false", + ) # Relationships search_space = relationship("SearchSpace", back_populates="new_chat_threads") From f42a11023fbd02fac296809197981efd00f26aa4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 19:55:53 +0200 Subject: [PATCH 03/42] create migration skeleton for public chat sharing --- .../79_add_public_share_to_chat_threads.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py diff --git a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py new file mode 100644 index 000000000..a9cf085d3 --- /dev/null +++ b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py @@ -0,0 +1,27 @@ +"""Add public sharing columns to new_chat_threads + +Revision ID: 79 +Revises: 78 +Create Date: 2026-01-23 + +Adds public_share_token and public_share_enabled columns to enable +public sharing of chat threads via secure tokenized URLs. +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "79" +down_revision: str | None = "78" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add public sharing columns to new_chat_threads.""" + pass + + +def downgrade() -> None: + """Remove public sharing columns from new_chat_threads.""" + pass From 87183d1eb7732877dc0afc88d1813a5f8bf2c48b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 19:57:08 +0200 Subject: [PATCH 04/42] add public_share_token column to migration --- .../versions/79_add_public_share_to_chat_threads.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py index a9cf085d3..70e262b9c 100644 --- a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py +++ b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py @@ -10,6 +10,10 @@ public sharing of chat threads via secure tokenized URLs. from collections.abc import Sequence +import sqlalchemy as sa + +from alembic import op + # revision identifiers, used by Alembic. revision: str = "79" down_revision: str | None = "78" @@ -19,9 +23,13 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Add public sharing columns to new_chat_threads.""" - pass + # Add public_share_token column + op.add_column( + "new_chat_threads", + sa.Column("public_share_token", sa.String(64), nullable=True), + ) def downgrade() -> None: """Remove public sharing columns from new_chat_threads.""" - pass + op.drop_column("new_chat_threads", "public_share_token") From b61d96fb85453b7109a52b6b8b5b97cb43b0887b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 19:57:53 +0200 Subject: [PATCH 05/42] add public_share_enabled column to migration --- .../versions/79_add_public_share_to_chat_threads.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py index 70e262b9c..d82f8f56f 100644 --- a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py +++ b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py @@ -29,7 +29,19 @@ def upgrade() -> None: sa.Column("public_share_token", sa.String(64), nullable=True), ) + # Add public_share_enabled column + op.add_column( + "new_chat_threads", + sa.Column( + "public_share_enabled", + sa.Boolean(), + nullable=False, + server_default="false", + ), + ) + def downgrade() -> None: """Remove public sharing columns from new_chat_threads.""" + op.drop_column("new_chat_threads", "public_share_enabled") op.drop_column("new_chat_threads", "public_share_token") From 036104e4c845b05fb034eb38e932cadbbb4cdd8f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 19:58:34 +0200 Subject: [PATCH 06/42] add unique index on public_share_token --- .../versions/79_add_public_share_to_chat_threads.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py index d82f8f56f..25195d6ee 100644 --- a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py +++ b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py @@ -40,8 +40,20 @@ def upgrade() -> None: ), ) + # Add unique partial index on public_share_token (only non-null values) + op.execute( + """ + CREATE UNIQUE INDEX ix_new_chat_threads_public_share_token + ON new_chat_threads(public_share_token) + WHERE public_share_token IS NOT NULL + """ + ) + def downgrade() -> None: """Remove public sharing columns from new_chat_threads.""" + op.drop_index( + "ix_new_chat_threads_public_share_token", table_name="new_chat_threads" + ) op.drop_column("new_chat_threads", "public_share_enabled") op.drop_column("new_chat_threads", "public_share_token") From a627cc709e95fb0aa409bdff906930c6204b54d4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 19:59:12 +0200 Subject: [PATCH 07/42] add index on public_share_enabled --- .../versions/79_add_public_share_to_chat_threads.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py index 25195d6ee..ce02064c0 100644 --- a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py +++ b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py @@ -49,9 +49,21 @@ def upgrade() -> None: """ ) + # Add partial index on public_share_enabled for fast public chat queries + op.execute( + """ + CREATE INDEX ix_new_chat_threads_public_share_enabled + ON new_chat_threads(public_share_enabled) + WHERE public_share_enabled = TRUE + """ + ) + def downgrade() -> None: """Remove public sharing columns from new_chat_threads.""" + op.drop_index( + "ix_new_chat_threads_public_share_enabled", table_name="new_chat_threads" + ) op.drop_index( "ix_new_chat_threads_public_share_token", table_name="new_chat_threads" ) From 68dc7723af527684cc12a0e006c1a2f860f28259 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 20:00:18 +0200 Subject: [PATCH 08/42] make migration idempotent --- .../versions/79_add_public_share_to_chat_threads.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py index ce02064c0..cb7ba555f 100644 --- a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py +++ b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py @@ -43,7 +43,7 @@ def upgrade() -> None: # Add unique partial index on public_share_token (only non-null values) op.execute( """ - CREATE UNIQUE INDEX ix_new_chat_threads_public_share_token + CREATE UNIQUE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_token ON new_chat_threads(public_share_token) WHERE public_share_token IS NOT NULL """ @@ -52,7 +52,7 @@ def upgrade() -> None: # Add partial index on public_share_enabled for fast public chat queries op.execute( """ - CREATE INDEX ix_new_chat_threads_public_share_enabled + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_enabled ON new_chat_threads(public_share_enabled) WHERE public_share_enabled = TRUE """ @@ -61,11 +61,7 @@ def upgrade() -> None: def downgrade() -> None: """Remove public sharing columns from new_chat_threads.""" - op.drop_index( - "ix_new_chat_threads_public_share_enabled", table_name="new_chat_threads" - ) - op.drop_index( - "ix_new_chat_threads_public_share_token", table_name="new_chat_threads" - ) + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_enabled") + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_token") op.drop_column("new_chat_threads", "public_share_enabled") op.drop_column("new_chat_threads", "public_share_token") From 3a8a9734d6eda24c80abc895b80454080f4f7ca3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 23 Jan 2026 20:02:42 +0200 Subject: [PATCH 09/42] refactor migration to use op.execute pattern --- .../79_add_public_share_to_chat_threads.py | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py index cb7ba555f..e1c21a353 100644 --- a/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py +++ b/surfsense_backend/alembic/versions/79_add_public_share_to_chat_threads.py @@ -10,8 +10,6 @@ public sharing of chat threads via secure tokenized URLs. from collections.abc import Sequence -import sqlalchemy as sa - from alembic import op # revision identifiers, used by Alembic. @@ -24,20 +22,19 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Add public sharing columns to new_chat_threads.""" # Add public_share_token column - op.add_column( - "new_chat_threads", - sa.Column("public_share_token", sa.String(64), nullable=True), + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS public_share_token VARCHAR(64); + """ ) - # Add public_share_enabled column - op.add_column( - "new_chat_threads", - sa.Column( - "public_share_enabled", - sa.Boolean(), - nullable=False, - server_default="false", - ), + # Add public_share_enabled column with default false + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS public_share_enabled BOOLEAN NOT NULL DEFAULT FALSE; + """ ) # Add unique partial index on public_share_token (only non-null values) @@ -45,7 +42,7 @@ def upgrade() -> None: """ CREATE UNIQUE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_token ON new_chat_threads(public_share_token) - WHERE public_share_token IS NOT NULL + WHERE public_share_token IS NOT NULL; """ ) @@ -54,7 +51,7 @@ def upgrade() -> None: """ CREATE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_enabled ON new_chat_threads(public_share_enabled) - WHERE public_share_enabled = TRUE + WHERE public_share_enabled = TRUE; """ ) @@ -63,5 +60,7 @@ def downgrade() -> None: """Remove public sharing columns from new_chat_threads.""" op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_enabled") op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_token") - op.drop_column("new_chat_threads", "public_share_enabled") - op.drop_column("new_chat_threads", "public_share_token") + op.execute( + "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_enabled" + ) + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_token") From 91543f7a7341be1ef8cb05f94c5bf32c253ca6cb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 12:50:56 +0200 Subject: [PATCH 10/42] add public chat schemas --- surfsense_backend/app/schemas/new_chat.py | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 7a29fc678..5062dd846 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -204,3 +204,55 @@ class RegenerateRequest(BaseModel): attachments: list[ChatAttachment] | None = None mentioned_document_ids: list[int] | None = None mentioned_surfsense_doc_ids: list[int] | None = None + + +# ============================================================================= +# Public Sharing Schemas +# ============================================================================= + + +class PublicShareToggleRequest(BaseModel): + """Request to enable/disable public sharing for a thread.""" + + enabled: bool + + +class PublicShareToggleResponse(BaseModel): + """Response after toggling public sharing.""" + + enabled: bool + public_url: str | None = None + share_token: str | None = None + + +# ============================================================================= +# Public Chat View Schemas (for unauthenticated access) +# ============================================================================= + + +class PublicAuthor(BaseModel): + display_name: str | None = None + avatar_url: str | None = None + + +class PublicChatMessage(BaseModel): + role: NewChatMessageRole + content: Any + author: PublicAuthor | None = None + created_at: datetime + + +class PublicChatThread(BaseModel): + title: str + created_at: datetime + + +class PublicChatResponse(BaseModel): + thread: PublicChatThread + messages: list[PublicChatMessage] + + +class CloneInitiatedResponse(BaseModel): + status: str = "processing" + task_id: str + message: str = "Copying chat to your account..." From 5fae0f5a584a6a947ad9add9aed76ea99c6cee0f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 13:07:46 +0200 Subject: [PATCH 11/42] add public chat service --- .../app/services/public_chat_service.py | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 surfsense_backend/app/services/public_chat_service.py diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py new file mode 100644 index 000000000..af35834b0 --- /dev/null +++ b/surfsense_backend/app/services/public_chat_service.py @@ -0,0 +1,199 @@ +""" +Service layer for public chat sharing and cloning. +""" + +import re +import secrets +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db import NewChatThread, User + +UI_TOOLS = { + "display_image", + "link_preview", + "generate_podcast", + "scrape_webpage", + "multi_link_preview", +} + + +def strip_citations(text: str) -> str: + """Remove [citation:X] and [citation:doc-X] patterns from text.""" + text = re.sub(r"\[citation:(doc-)?\d+\]", "", text) + text = re.sub(r"\s+", " ", text) + return text.strip() + + +def sanitize_content_for_public(content: list | str | None) -> list: + """Filter message content for public view.""" + if content is None: + return [] + + if isinstance(content, str): + clean_text = strip_citations(content) + return [{"type": "text", "text": clean_text}] if clean_text else [] + + if not isinstance(content, list): + return [] + + sanitized = [] + for part in content: + if not isinstance(part, dict): + continue + + part_type = part.get("type") + + if part_type == "text": + clean_text = strip_citations(part.get("text", "")) + if clean_text: + sanitized.append({"type": "text", "text": clean_text}) + + elif part_type == "tool-call": + if part.get("toolName") in UI_TOOLS: + sanitized.append(part) + + return sanitized + + +async def get_author_display( + session: AsyncSession, + author_id: UUID | None, + user_cache: dict[UUID, dict], +) -> dict | None: + """Transform author UUID to display info.""" + if author_id is None: + return None + + if author_id not in user_cache: + result = await session.execute(select(User).filter(User.id == author_id)) + user = result.scalars().first() + if user: + user_cache[author_id] = { + "display_name": user.display_name or "User", + "avatar_url": user.avatar_url, + } + else: + user_cache[author_id] = { + "display_name": "Unknown User", + "avatar_url": None, + } + + return user_cache[author_id] + + +async def toggle_public_share( + session: AsyncSession, + thread_id: int, + enabled: bool, + user: User, + base_url: str, +) -> dict: + """ + Enable or disable public sharing for a thread. + + Only the thread owner can toggle public sharing. + When enabling, generates a new token if one doesn't exist. + When disabling, keeps the token for potential re-enable. + """ + result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == thread_id) + ) + thread = result.scalars().first() + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + if thread.created_by_id != user.id: + raise HTTPException( + status_code=403, + detail="Only the creator of this chat can manage public sharing", + ) + + if enabled and not thread.public_share_token: + thread.public_share_token = secrets.token_urlsafe(48) + + thread.public_share_enabled = enabled + + await session.commit() + await session.refresh(thread) + + if enabled: + return { + "enabled": True, + "public_url": f"{base_url}/public/{thread.public_share_token}", + "share_token": thread.public_share_token, + } + + return { + "enabled": False, + "public_url": None, + "share_token": None, + } + + +async def get_public_chat( + session: AsyncSession, + share_token: str, +) -> dict: + """ + Get a public chat by share token. + + Returns sanitized content suitable for public viewing. + """ + result = await session.execute( + select(NewChatThread) + .options(selectinload(NewChatThread.messages)) + .filter( + NewChatThread.public_share_token == share_token, + NewChatThread.public_share_enabled.is_(True), + ) + ) + thread = result.scalars().first() + + if not thread: + raise HTTPException(status_code=404, detail="Not found") + + user_cache: dict[UUID, dict] = {} + + messages = [] + for msg in sorted(thread.messages, key=lambda m: m.created_at): + author = await get_author_display(session, msg.author_id, user_cache) + sanitized_content = sanitize_content_for_public(msg.content) + + messages.append( + { + "role": msg.role, + "content": sanitized_content, + "author": author, + "created_at": msg.created_at, + } + ) + + return { + "thread": { + "title": thread.title, + "created_at": thread.created_at, + }, + "messages": messages, + } + + +async def get_thread_by_share_token( + session: AsyncSession, + share_token: str, +) -> NewChatThread | None: + """Get a thread by its public share token if sharing is enabled.""" + result = await session.execute( + select(NewChatThread) + .options(selectinload(NewChatThread.messages)) + .filter( + NewChatThread.public_share_token == share_token, + NewChatThread.public_share_enabled.is_(True), + ) + ) + return result.scalars().first() From 88a9e623ba5b32ada9b3eccf698a975806f2f74d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 13:18:21 +0200 Subject: [PATCH 12/42] add public chat routes --- surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/public_chat_routes.py | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 surfsense_backend/app/routes/public_chat_routes.py diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 76bb5101a..81bd887a5 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -30,6 +30,7 @@ from .notes_routes import router as notes_router from .notifications_routes import router as notifications_router from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router +from .public_chat_routes import router as public_chat_router from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router @@ -67,3 +68,4 @@ router.include_router(circleback_webhook_router) # Circleback meeting webhooks router.include_router(surfsense_docs_router) # Surfsense documentation for citations router.include_router(notifications_router) # Notifications with Electric SQL sync router.include_router(composio_router) # Composio OAuth and toolkit management +router.include_router(public_chat_router) # Public chat sharing and cloning diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py new file mode 100644 index 000000000..916a53249 --- /dev/null +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -0,0 +1,63 @@ +""" +Routes for public chat access (unauthenticated and mixed-auth endpoints). +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import User, get_async_session +from app.schemas.new_chat import ( + CloneInitiatedResponse, + PublicChatResponse, +) +from app.services.public_chat_service import ( + get_public_chat, + get_thread_by_share_token, +) +from app.users import current_active_user + +router = APIRouter(prefix="/public", tags=["public"]) + + +@router.get("/{share_token}", response_model=PublicChatResponse) +async def read_public_chat( + share_token: str, + session: AsyncSession = Depends(get_async_session), +): + """ + Get a public chat by share token. + + No authentication required. + Returns sanitized content (citations stripped, non-UI tools removed). + """ + return await get_public_chat(session, share_token) + + +@router.post("/{share_token}/clone", response_model=CloneInitiatedResponse) +async def clone_public_chat( + share_token: str, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Clone a public chat to the user's account. + + Requires authentication. + Initiates a background job to copy the chat. + """ + thread = await get_thread_by_share_token(session, share_token) + + if not thread: + raise HTTPException(status_code=404, detail="Not found") + + # TODO: Implement Celery task for cloning + # For now, return a placeholder response + # The actual implementation will: + # 1. Get user's default search space + # 2. Queue Celery task to clone thread, messages, and podcasts + # 3. Create notification on completion + + raise HTTPException( + status_code=501, + detail="Clone functionality not yet implemented", + ) From 90cf6d4b1b6236c76fd424d76b7f89f5413e9a95 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 13:22:47 +0200 Subject: [PATCH 13/42] add public share toggle endpoint --- .../app/routes/new_chat_routes.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 7631ec7eb..a619b8892 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -45,11 +45,14 @@ from app.schemas.new_chat import ( NewChatThreadUpdate, NewChatThreadVisibilityUpdate, NewChatThreadWithMessages, + PublicShareToggleRequest, + PublicShareToggleResponse, RegenerateRequest, ThreadHistoryLoadResponse, ThreadListItem, ThreadListResponse, ) +from app.services.public_chat_service import toggle_public_share from app.tasks.chat.stream_new_chat import stream_new_chat from app.users import current_active_user from app.utils.rbac import check_permission @@ -729,6 +732,32 @@ async def update_thread_visibility( ) from None +@router.patch( + "/threads/{thread_id}/public-share", response_model=PublicShareToggleResponse +) +async def update_thread_public_share( + thread_id: int, + request: Request, + toggle_request: PublicShareToggleRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Enable or disable public sharing for a thread. + + Only the creator of the thread can manage public sharing. + When enabled, returns a public URL that anyone can use to view the chat. + """ + base_url = str(request.base_url).rstrip("/") + return await toggle_public_share( + session=session, + thread_id=thread_id, + enabled=toggle_request.enabled, + user=user, + base_url=base_url, + ) + + # ============================================================================= # Message Endpoints # ============================================================================= From 37526b74a95e843afc3fc5de6fb9541c8d8954e7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 14:36:52 +0200 Subject: [PATCH 14/42] add public_share_enabled to thread response schemas --- surfsense_backend/app/routes/new_chat_routes.py | 2 ++ surfsense_backend/app/schemas/new_chat.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index a619b8892..4571e9051 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -218,6 +218,7 @@ async def list_threads( visibility=thread.visibility, created_by_id=thread.created_by_id, is_own_thread=is_own_thread, + public_share_enabled=thread.public_share_enabled, created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -319,6 +320,7 @@ async def search_threads( thread.created_by_id == user.id or (thread.created_by_id is None and is_search_space_owner) ), + public_share_enabled=thread.public_share_enabled, created_at=thread.created_at, updated_at=thread.updated_at, ) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 5062dd846..ef2868495 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -95,6 +95,7 @@ class NewChatThreadRead(NewChatThreadBase, IDModel): search_space_id: int visibility: ChatVisibility created_by_id: UUID | None = None + public_share_enabled: bool = False created_at: datetime updated_at: datetime @@ -133,7 +134,8 @@ class ThreadListItem(BaseModel): archived: bool visibility: ChatVisibility created_by_id: UUID | None = None - is_own_thread: bool = False # True if the current user created this thread + is_own_thread: bool = False + public_share_enabled: bool = False created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") From 1ab084aa3136a98b35d3275d63439e31a1f93eda Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 15:03:28 +0200 Subject: [PATCH 15/42] add clone public chat service logic --- .../app/services/public_chat_service.py | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index af35834b0..5c2793451 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -197,3 +197,218 @@ async def get_thread_by_share_token( ) ) return result.scalars().first() + + +async def get_user_default_search_space( + session: AsyncSession, + user_id: UUID, +) -> int | None: + """ + Get user's default search space for cloning. + + Returns the first search space where user is owner, or None if not found. + """ + from app.db import SearchSpaceMembership + + result = await session.execute( + select(SearchSpaceMembership) + .filter( + SearchSpaceMembership.user_id == user_id, + SearchSpaceMembership.is_owner.is_(True), + ) + .limit(1) + ) + membership = result.scalars().first() + + if membership: + return membership.search_space_id + + return None + + +async def clone_public_chat( + session: AsyncSession, + share_token: str, + user_id: UUID, +) -> dict: + """ + Clone a public chat to user's account. + + Creates a new private thread with all messages and podcasts. + """ + import copy + + from app.db import ( + ChatVisibility, + NewChatMessage, + ) + + source_thread = await get_thread_by_share_token(session, share_token) + if not source_thread: + await _create_clone_failure_notification( + session, user_id, share_token, "Chat not found or no longer public" + ) + return {"status": "error", "error": "Chat not found or no longer public"} + + try: + target_search_space_id = await get_user_default_search_space(session, user_id) + + if target_search_space_id is None: + await _create_clone_failure_notification( + session, user_id, share_token, "No search space found" + ) + return {"status": "error", "error": "No search space found"} + + new_thread = NewChatThread( + title=source_thread.title, + archived=False, + visibility=ChatVisibility.PRIVATE, + search_space_id=target_search_space_id, + created_by_id=user_id, + public_share_enabled=False, + ) + session.add(new_thread) + await session.flush() + + podcast_id_map: dict[int, int] = {} + + for msg in sorted(source_thread.messages, key=lambda m: m.created_at): + new_content = copy.deepcopy(msg.content) + + if isinstance(new_content, list): + for part in new_content: + if ( + isinstance(part, dict) + and part.get("type") == "tool-call" + and part.get("toolName") == "generate_podcast" + ): + result = part.get("result", {}) + old_podcast_id = result.get("podcast_id") + if old_podcast_id and old_podcast_id not in podcast_id_map: + new_podcast_id = await _clone_podcast( + session, + old_podcast_id, + target_search_space_id, + ) + if new_podcast_id: + podcast_id_map[old_podcast_id] = new_podcast_id + + if old_podcast_id and old_podcast_id in podcast_id_map: + result["podcast_id"] = podcast_id_map[old_podcast_id] + + new_message = NewChatMessage( + thread_id=new_thread.id, + role=msg.role, + content=new_content, + author_id=msg.author_id, + created_at=msg.created_at, + ) + session.add(new_message) + + await session.commit() + + await _create_clone_success_notification( + session, + user_id, + new_thread.id, + target_search_space_id, + source_thread.title, + ) + + return { + "status": "success", + "thread_id": new_thread.id, + "search_space_id": target_search_space_id, + } + + except Exception as e: + await session.rollback() + await _create_clone_failure_notification(session, user_id, share_token, str(e)) + return {"status": "error", "error": str(e)} + + +async def _clone_podcast( + session: AsyncSession, + podcast_id: int, + target_search_space_id: int, +) -> int | None: + """Clone a podcast record and its audio file.""" + import shutil + import uuid + from pathlib import Path + + from app.db import Podcast + + result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) + original = result.scalars().first() + if not original: + return None + + new_file_path = None + if original.file_location: + original_path = Path(original.file_location) + if original_path.exists(): + new_filename = f"{uuid.uuid4()}_podcast.mp3" + new_dir = Path("podcasts") + new_dir.mkdir(parents=True, exist_ok=True) + new_file_path = str(new_dir / new_filename) + shutil.copy2(original.file_location, new_file_path) + + new_podcast = Podcast( + title=original.title, + podcast_transcript=original.podcast_transcript, + file_location=new_file_path, + search_space_id=target_search_space_id, + ) + session.add(new_podcast) + await session.flush() + + return new_podcast.id + + +async def _create_clone_success_notification( + session: AsyncSession, + user_id: UUID, + thread_id: int, + search_space_id: int, + original_title: str, +) -> None: + """Create success notification for clone operation.""" + from app.db import Notification + + notification = Notification( + user_id=user_id, + search_space_id=search_space_id, + type="chat_cloned", + title="Chat copied successfully", + message=f"Your copy of '{original_title}' is ready", + notification_metadata={ + "thread_id": thread_id, + "search_space_id": search_space_id, + }, + ) + session.add(notification) + await session.commit() + + +async def _create_clone_failure_notification( + session: AsyncSession, + user_id: UUID, + share_token: str, + error: str, +) -> None: + """Create failure notification for clone operation.""" + from app.db import Notification + + notification = Notification( + user_id=user_id, + type="chat_clone_failed", + title="Failed to copy chat", + message="Could not copy the chat. Please try again.", + notification_metadata={ + "share_token": share_token, + "error": error, + }, + ) + session.add(notification) + await session.commit() From 272e67566905ef227faaddb863a79c19a917a5eb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 15:03:35 +0200 Subject: [PATCH 16/42] add clone celery task and update route --- .../app/routes/public_chat_routes.py | 25 +++---- .../tasks/celery_tasks/clone_chat_tasks.py | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 surfsense_backend/app/tasks/celery_tasks/clone_chat_tasks.py diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index 916a53249..ca70e911a 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -28,13 +28,13 @@ async def read_public_chat( Get a public chat by share token. No authentication required. - Returns sanitized content (citations stripped, non-UI tools removed). + Returns sanitized content (citations stripped). """ return await get_public_chat(session, share_token) @router.post("/{share_token}/clone", response_model=CloneInitiatedResponse) -async def clone_public_chat( +async def clone_public_chat_endpoint( share_token: str, session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), @@ -45,19 +45,20 @@ async def clone_public_chat( Requires authentication. Initiates a background job to copy the chat. """ + from app.tasks.celery_tasks.clone_chat_tasks import clone_public_chat_task + thread = await get_thread_by_share_token(session, share_token) if not thread: raise HTTPException(status_code=404, detail="Not found") - # TODO: Implement Celery task for cloning - # For now, return a placeholder response - # The actual implementation will: - # 1. Get user's default search space - # 2. Queue Celery task to clone thread, messages, and podcasts - # 3. Create notification on completion - - raise HTTPException( - status_code=501, - detail="Clone functionality not yet implemented", + task_result = clone_public_chat_task.delay( + share_token=share_token, + user_id=str(user.id), + ) + + return CloneInitiatedResponse( + status="processing", + task_id=task_result.id, + message="Copying chat to your account...", ) diff --git a/surfsense_backend/app/tasks/celery_tasks/clone_chat_tasks.py b/surfsense_backend/app/tasks/celery_tasks/clone_chat_tasks.py new file mode 100644 index 000000000..b846ee555 --- /dev/null +++ b/surfsense_backend/app/tasks/celery_tasks/clone_chat_tasks.py @@ -0,0 +1,66 @@ +"""Celery tasks for cloning public chats.""" + +import asyncio +import logging + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.celery_app import celery_app +from app.config import config + +logger = logging.getLogger(__name__) + + +def get_celery_session_maker(): + """Create a new async session maker for Celery tasks.""" + engine = create_async_engine( + config.DATABASE_URL, + poolclass=NullPool, + echo=False, + ) + return async_sessionmaker(engine, expire_on_commit=False) + + +@celery_app.task(name="clone_public_chat", bind=True) +def clone_public_chat_task( + self, + share_token: str, + user_id: str, +) -> dict: + """ + Celery task to clone a public chat to user's account. + + Args: + share_token: Public share token of the chat to clone + user_id: UUID string of the user cloning the chat + + Returns: + dict with status and thread_id on success, or error info on failure + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + result = loop.run_until_complete(_run_clone(share_token, user_id)) + return result + except Exception as e: + logger.error(f"Error cloning public chat: {e!s}") + return {"status": "error", "error": str(e)} + finally: + asyncio.set_event_loop(None) + loop.close() + + +async def _run_clone(share_token: str, user_id: str) -> dict: + """Run the clone operation with a fresh database session.""" + from uuid import UUID + + from app.services.public_chat_service import clone_public_chat + + async with get_celery_session_maker()() as session: + return await clone_public_chat( + session=session, + share_token=share_token, + user_id=UUID(user_id), + ) From 062998738a3c5326d9fe83d7ffd359acd47990d1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 15:56:15 +0200 Subject: [PATCH 17/42] feat: add thread_id column to Podcast model --- .../versions/80_add_thread_id_to_podcasts.py | 40 +++++++++++++++++++ surfsense_backend/app/db.py | 8 ++++ 2 files changed, 48 insertions(+) create mode 100644 surfsense_backend/alembic/versions/80_add_thread_id_to_podcasts.py diff --git a/surfsense_backend/alembic/versions/80_add_thread_id_to_podcasts.py b/surfsense_backend/alembic/versions/80_add_thread_id_to_podcasts.py new file mode 100644 index 000000000..ea66a09a1 --- /dev/null +++ b/surfsense_backend/alembic/versions/80_add_thread_id_to_podcasts.py @@ -0,0 +1,40 @@ +"""Add thread_id to podcasts + +Revision ID: 80 +Revises: 79 +Create Date: 2026-01-23 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "80" +down_revision: str | None = "79" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add thread_id column to podcasts.""" + op.execute( + """ + ALTER TABLE podcasts + ADD COLUMN IF NOT EXISTS thread_id INTEGER + REFERENCES new_chat_threads(id) ON DELETE SET NULL; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_podcasts_thread_id + ON podcasts(thread_id); + """ + ) + + +def downgrade() -> None: + """Remove thread_id column from podcasts.""" + op.execute("DROP INDEX IF EXISTS ix_podcasts_thread_id") + op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS thread_id") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index d13e0cbdd..7018e613c 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -693,6 +693,14 @@ class Podcast(BaseModel, TimestampMixin): ) search_space = relationship("SearchSpace", back_populates="podcasts") + thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + thread = relationship("NewChatThread") + class SearchSpace(BaseModel, TimestampMixin): __tablename__ = "searchspaces" From 7017a14107e8e06f2d16d74470f225cdc3ff3741 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 15:56:34 +0200 Subject: [PATCH 18/42] feat: pass thread_id through podcast generation chain --- surfsense_backend/app/agents/new_chat/chat_deepagent.py | 2 ++ surfsense_backend/app/agents/new_chat/tools/podcast.py | 3 +++ surfsense_backend/app/agents/new_chat/tools/registry.py | 3 ++- surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py | 5 +++++ surfsense_backend/app/tasks/chat/stream_new_chat.py | 1 + 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 5bc6ac2e2..1a2029c42 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -35,6 +35,7 @@ async def create_surfsense_deep_agent( connector_service: ConnectorService, checkpointer: Checkpointer, user_id: str | None = None, + thread_id: int | None = None, agent_config: AgentConfig | None = None, enabled_tools: list[str] | None = None, disabled_tools: list[str] | None = None, @@ -123,6 +124,7 @@ async def create_surfsense_deep_agent( "connector_service": connector_service, "firecrawl_api_key": firecrawl_api_key, "user_id": user_id, # Required for memory tools + "thread_id": thread_id, # For podcast tool } # Build tools using the async registry (includes MCP tools) diff --git a/surfsense_backend/app/agents/new_chat/tools/podcast.py b/surfsense_backend/app/agents/new_chat/tools/podcast.py index ff567bf73..d4e023f6f 100644 --- a/surfsense_backend/app/agents/new_chat/tools/podcast.py +++ b/surfsense_backend/app/agents/new_chat/tools/podcast.py @@ -69,6 +69,7 @@ def clear_active_podcast_task(search_space_id: int) -> None: def create_generate_podcast_tool( search_space_id: int, db_session: AsyncSession, + thread_id: int | None = None, ): """ Factory function to create the generate_podcast tool with injected dependencies. @@ -76,6 +77,7 @@ def create_generate_podcast_tool( Args: search_space_id: The user's search space ID db_session: Database session (not used - Celery creates its own) + thread_id: The chat thread ID for associating the podcast Returns: A configured tool function for generating podcasts @@ -145,6 +147,7 @@ def create_generate_podcast_tool( search_space_id=search_space_id, podcast_title=podcast_title, user_prompt=user_prompt, + thread_id=thread_id, ) # Mark this task as active diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index e4ce7a6b7..8eeff18b8 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -102,8 +102,9 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ factory=lambda deps: create_generate_podcast_tool( search_space_id=deps["search_space_id"], db_session=deps["db_session"], + thread_id=deps["thread_id"], ), - requires=["search_space_id", "db_session"], + requires=["search_space_id", "db_session", "thread_id"], ), # Link preview tool - fetches Open Graph metadata for URLs ToolDefinition( diff --git a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py index 34b9b827c..862234b46 100644 --- a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py @@ -67,6 +67,7 @@ def generate_content_podcast_task( search_space_id: int, podcast_title: str = "SurfSense Podcast", user_prompt: str | None = None, + thread_id: int | None = None, ) -> dict: """ Celery task to generate podcast from source content (for new-chat). @@ -78,6 +79,7 @@ def generate_content_podcast_task( search_space_id: ID of the search space podcast_title: Title for the podcast user_prompt: Optional instructions for podcast style/tone + thread_id: Optional ID of the chat thread that generated this podcast Returns: dict with podcast_id on success, or error info on failure @@ -92,6 +94,7 @@ def generate_content_podcast_task( search_space_id, podcast_title, user_prompt, + thread_id, ) ) loop.run_until_complete(loop.shutdown_asyncgens()) @@ -111,6 +114,7 @@ async def _generate_content_podcast( search_space_id: int, podcast_title: str = "SurfSense Podcast", user_prompt: str | None = None, + thread_id: int | None = None, ) -> dict: """Generate content-based podcast with new session.""" async with get_celery_session_maker()() as session: @@ -158,6 +162,7 @@ async def _generate_content_podcast( podcast_transcript=serializable_transcript, file_location=file_path, search_space_id=search_space_id, + thread_id=thread_id, ) session.add(podcast) await session.commit() diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index a49c244eb..af09c4702 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -255,6 +255,7 @@ async def stream_new_chat( connector_service=connector_service, checkpointer=checkpointer, user_id=user_id, # Pass user ID for memory tools + thread_id=chat_id, # Pass chat ID for podcast association agent_config=agent_config, # Pass prompt configuration firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured ) From aeb0deb21eab21b62f9d6300b26d7cfa196386f9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 15:56:49 +0200 Subject: [PATCH 19/42] feat: enable public access for podcasts in shared chats --- .../app/routes/podcasts_routes.py | 41 ++++++++++--------- .../app/services/public_chat_service.py | 27 ++++++++++++ surfsense_backend/app/users.py | 1 + 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/surfsense_backend/app/routes/podcasts_routes.py b/surfsense_backend/app/routes/podcasts_routes.py index ef362edb5..467ef8d23 100644 --- a/surfsense_backend/app/routes/podcasts_routes.py +++ b/surfsense_backend/app/routes/podcasts_routes.py @@ -25,7 +25,7 @@ from app.db import ( get_async_session, ) from app.schemas import PodcastRead -from app.users import current_active_user +from app.users import current_active_user, current_optional_user from app.utils.rbac import check_permission router = APIRouter() @@ -161,46 +161,49 @@ async def delete_podcast( async def stream_podcast( podcast_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User | None = Depends(current_optional_user), ): """ Stream a podcast audio file. - Requires PODCASTS_READ permission for the search space. + + Access is allowed if: + - User is authenticated with PODCASTS_READ permission, OR + - Podcast belongs to a publicly shared thread Note: Both /stream and /audio endpoints are supported for compatibility. """ + from app.services.public_chat_service import is_podcast_publicly_accessible + try: result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) podcast = result.scalars().first() if not podcast: - raise HTTPException( - status_code=404, - detail="Podcast not found", + raise HTTPException(status_code=404, detail="Podcast not found") + + is_public = await is_podcast_publicly_accessible(session, podcast_id) + + if not is_public: + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + await check_permission( + session, + user, + podcast.search_space_id, + Permission.PODCASTS_READ.value, + "You don't have permission to access podcasts in this search space", ) - # Check permission for the search space - await check_permission( - session, - user, - podcast.search_space_id, - Permission.PODCASTS_READ.value, - "You don't have permission to access podcasts in this search space", - ) - - # Get the file path file_path = podcast.file_location - # Check if the file exists if not file_path or not os.path.isfile(file_path): raise HTTPException(status_code=404, detail="Podcast audio file not found") - # Define a generator function to stream the file def iterfile(): with open(file_path, mode="rb") as file_like: yield from file_like - # Return a streaming response with appropriate headers return StreamingResponse( iterfile(), media_type="audio/mpeg", diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 5c2793451..08523c1f2 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -289,6 +289,7 @@ async def clone_public_chat( session, old_podcast_id, target_search_space_id, + new_thread.id, ) if new_podcast_id: podcast_id_map[old_podcast_id] = new_podcast_id @@ -331,6 +332,7 @@ async def _clone_podcast( session: AsyncSession, podcast_id: int, target_search_space_id: int, + target_thread_id: int, ) -> int | None: """Clone a podcast record and its audio file.""" import shutil @@ -359,6 +361,7 @@ async def _clone_podcast( podcast_transcript=original.podcast_transcript, file_location=new_file_path, search_space_id=target_search_space_id, + thread_id=target_thread_id, ) session.add(new_podcast) await session.flush() @@ -412,3 +415,27 @@ async def _create_clone_failure_notification( ) session.add(notification) await session.commit() + + +async def is_podcast_publicly_accessible( + session: AsyncSession, + podcast_id: int, +) -> bool: + """ + Check if a podcast belongs to a publicly shared thread. + + Uses the thread_id foreign key for efficient lookup. + """ + from app.db import Podcast + + result = await session.execute( + select(Podcast) + .options(selectinload(Podcast.thread)) + .filter(Podcast.id == podcast_id) + ) + podcast = result.scalars().first() + + if not podcast or not podcast.thread: + return False + + return podcast.thread.public_share_enabled diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index e86eb752b..4be2fe525 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -229,3 +229,4 @@ auth_backend = AuthenticationBackend( fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) current_active_user = fastapi_users.current_user(active=True) +current_optional_user = fastapi_users.current_user(active=True, optional=True) From 9d7259aab941c5c8b32334a3aa96732221d04ead Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 16:11:55 +0200 Subject: [PATCH 20/42] feat(web): add public chat and thread API types and services --- .../contracts/types/chat-threads.types.ts | 19 ++++++ .../contracts/types/public-chat.types.ts | 61 +++++++++++++++++++ surfsense_web/lib/apis/base-api.service.ts | 10 ++- .../lib/apis/chat-threads-api.service.ts | 33 ++++++++++ .../lib/apis/public-chat-api.service.ts | 49 +++++++++++++++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 surfsense_web/contracts/types/chat-threads.types.ts create mode 100644 surfsense_web/contracts/types/public-chat.types.ts create mode 100644 surfsense_web/lib/apis/chat-threads-api.service.ts create mode 100644 surfsense_web/lib/apis/public-chat-api.service.ts diff --git a/surfsense_web/contracts/types/chat-threads.types.ts b/surfsense_web/contracts/types/chat-threads.types.ts new file mode 100644 index 000000000..e5ca183bd --- /dev/null +++ b/surfsense_web/contracts/types/chat-threads.types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +/** + * Toggle public share + */ +export const togglePublicShareRequest = z.object({ + thread_id: z.number(), + enabled: z.boolean(), +}); + +export const togglePublicShareResponse = z.object({ + enabled: z.boolean(), + public_url: z.string().nullable(), + share_token: z.string().nullable(), +}); + +// Type exports +export type TogglePublicShareRequest = z.infer; +export type TogglePublicShareResponse = z.infer; diff --git a/surfsense_web/contracts/types/public-chat.types.ts b/surfsense_web/contracts/types/public-chat.types.ts new file mode 100644 index 000000000..709bedcb7 --- /dev/null +++ b/surfsense_web/contracts/types/public-chat.types.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +/** + * Author info for public chat + */ +export const publicAuthor = z.object({ + display_name: z.string().nullable(), + avatar_url: z.string().nullable(), +}); + +/** + * Message in a public chat + */ +export const publicChatMessage = z.object({ + role: z.string(), + content: z.unknown(), + author: publicAuthor.nullable(), + created_at: z.string(), +}); + +/** + * Thread info for public chat + */ +export const publicChatThread = z.object({ + title: z.string(), + created_at: z.string(), +}); + +/** + * Get public chat + */ +export const getPublicChatRequest = z.object({ + share_token: z.string(), +}); + +export const getPublicChatResponse = z.object({ + thread: publicChatThread, + messages: z.array(publicChatMessage), +}); + +/** + * Clone public chat + */ +export const clonePublicChatRequest = z.object({ + share_token: z.string(), +}); + +export const clonePublicChatResponse = z.object({ + status: z.string(), + task_id: z.string(), + message: z.string(), +}); + +// Type exports +export type PublicAuthor = z.infer; +export type PublicChatMessage = z.infer; +export type PublicChatThread = z.infer; +export type GetPublicChatRequest = z.infer; +export type GetPublicChatResponse = z.infer; +export type ClonePublicChatRequest = z.infer; +export type ClonePublicChatResponse = z.infer; diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index dcff4768b..a87d4deaf 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -23,7 +23,10 @@ export type RequestOptions = { class BaseApiService { baseUrl: string; - noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; + + // Prefixes that don't require auth (checked with startsWith) + noAuthPrefixes: string[] = ["/api/v1/public/"]; // Use a getter to always read fresh token from localStorage // This ensures the token is always up-to-date after login/logout @@ -84,7 +87,10 @@ class BaseApiService { } // Validate the bearer token - if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { + const isNoAuthEndpoint = + this.noAuthEndpoints.includes(url) || + this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)); + if (!this.bearerToken && !isNoAuthEndpoint) { throw new AuthenticationError("You are not authenticated. Please login again."); } diff --git a/surfsense_web/lib/apis/chat-threads-api.service.ts b/surfsense_web/lib/apis/chat-threads-api.service.ts new file mode 100644 index 000000000..9ad241c42 --- /dev/null +++ b/surfsense_web/lib/apis/chat-threads-api.service.ts @@ -0,0 +1,33 @@ +import { + type TogglePublicShareRequest, + type TogglePublicShareResponse, + togglePublicShareRequest, + togglePublicShareResponse, +} from "@/contracts/types/chat-threads.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class ChatThreadsApiService { + /** + * Toggle public sharing for a thread. + * Requires authentication. + */ + togglePublicShare = async ( + request: TogglePublicShareRequest + ): Promise => { + const parsed = togglePublicShareRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.patch( + `/api/v1/threads/${parsed.data.thread_id}/public-share`, + togglePublicShareResponse, + { body: { enabled: parsed.data.enabled } } + ); + }; +} + +export const chatThreadsApiService = new ChatThreadsApiService(); diff --git a/surfsense_web/lib/apis/public-chat-api.service.ts b/surfsense_web/lib/apis/public-chat-api.service.ts new file mode 100644 index 000000000..52a7c1363 --- /dev/null +++ b/surfsense_web/lib/apis/public-chat-api.service.ts @@ -0,0 +1,49 @@ +import { + type ClonePublicChatRequest, + type ClonePublicChatResponse, + clonePublicChatRequest, + clonePublicChatResponse, + type GetPublicChatRequest, + type GetPublicChatResponse, + getPublicChatRequest, + getPublicChatResponse, +} from "@/contracts/types/public-chat.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class PublicChatApiService { + /** + * Get a public chat by share token. + * No authentication required. + */ + getPublicChat = async (request: GetPublicChatRequest): Promise => { + const parsed = getPublicChatRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get(`/api/v1/public/${parsed.data.share_token}`, getPublicChatResponse); + }; + + /** + * Clone a public chat to the user's account. + * Requires authentication. + */ + clonePublicChat = async (request: ClonePublicChatRequest): Promise => { + const parsed = clonePublicChatRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post( + `/api/v1/public/${parsed.data.share_token}/clone`, + clonePublicChatResponse + ); + }; +} + +export const publicChatApiService = new PublicChatApiService(); From 37adc54d6a9ea85010df8a19a703e21126594bdd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 17:08:26 +0200 Subject: [PATCH 21/42] feat: add public chat frontend --- surfsense_web/app/public/[token]/page.tsx | 11 ++ surfsense_web/components/homepage/navbar.tsx | 8 +- .../public-chat/public-chat-footer.tsx | 56 ++++++ .../public-chat/public-chat-header.tsx | 34 ++++ .../public-chat/public-chat-view.tsx | 58 ++++++ .../components/public-chat/public-thread.tsx | 179 ++++++++++++++++++ .../hooks/use-public-chat-runtime.ts | 53 ++++++ surfsense_web/hooks/use-public-chat.ts | 14 ++ surfsense_web/lib/query-client/cache-keys.ts | 3 + 9 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/app/public/[token]/page.tsx create mode 100644 surfsense_web/components/public-chat/public-chat-footer.tsx create mode 100644 surfsense_web/components/public-chat/public-chat-header.tsx create mode 100644 surfsense_web/components/public-chat/public-chat-view.tsx create mode 100644 surfsense_web/components/public-chat/public-thread.tsx create mode 100644 surfsense_web/hooks/use-public-chat-runtime.ts create mode 100644 surfsense_web/hooks/use-public-chat.ts diff --git a/surfsense_web/app/public/[token]/page.tsx b/surfsense_web/app/public/[token]/page.tsx new file mode 100644 index 000000000..530664ac6 --- /dev/null +++ b/surfsense_web/app/public/[token]/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { PublicChatView } from "@/components/public-chat/public-chat-view"; + +export default function PublicChatPage() { + const params = useParams(); + const token = params.token as string; + + return ; +} diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index 2a8820bd6..c83d3556a 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -1,5 +1,11 @@ "use client"; -import { IconBrandDiscord, IconBrandGithub, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react"; +import { + IconBrandDiscord, + IconBrandGithub, + IconBrandReddit, + IconMenu2, + IconX, +} from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx new file mode 100644 index 000000000..06e3d9975 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Copy, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; +import { getBearerToken } from "@/lib/auth-utils"; + +interface PublicChatFooterProps { + shareToken: string; +} + +export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { + const router = useRouter(); + const [isCloning, setIsCloning] = useState(false); + + const handleCopyAndContinue = async () => { + const token = getBearerToken(); + + if (!token) { + const returnUrl = encodeURIComponent(`/public/${shareToken}`); + router.push(`/login?returnUrl=${returnUrl}&action=clone`); + return; + } + + setIsCloning(true); + + try { + await publicChatApiService.clonePublicChat({ + share_token: shareToken, + }); + + toast.success("Copying chat to your account...", { + description: "You'll be notified when it's ready.", + }); + + router.push("/dashboard"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to copy chat"; + toast.error(message); + } finally { + setIsCloning(false); + } + }; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/components/public-chat/public-chat-header.tsx b/surfsense_web/components/public-chat/public-chat-header.tsx new file mode 100644 index 000000000..6f6e40a52 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-header.tsx @@ -0,0 +1,34 @@ +import { formatDistanceToNow } from "date-fns"; +import Image from "next/image"; +import Link from "next/link"; + +interface PublicChatHeaderProps { + title: string; + createdAt: string; +} + +export function PublicChatHeader({ title, createdAt }: PublicChatHeaderProps) { + const timeAgo = formatDistanceToNow(new Date(createdAt), { addSuffix: true }); + + return ( +
+
+
+ + SurfSense + +
+

{title}

+

{timeAgo}

+
+
+
+
+ ); +} diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx new file mode 100644 index 000000000..1b7543712 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Loader2 } from "lucide-react"; +import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; +import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; +import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; +import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; +import { usePublicChat } from "@/hooks/use-public-chat"; +import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime"; +import { PublicChatFooter } from "./public-chat-footer"; +import { PublicChatHeader } from "./public-chat-header"; +import { PublicThread } from "./public-thread"; + +interface PublicChatViewProps { + shareToken: string; +} + +export function PublicChatView({ shareToken }: PublicChatViewProps) { + const { data, isLoading, error } = usePublicChat(shareToken); + const runtime = usePublicChatRuntime({ data }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+

Chat not found

+

+ This chat may have been removed or is no longer public. +

+
+ ); + } + + return ( + + {/* Tool UIs for rendering tool results */} + + + + + +
+ } + footer={} + /> +
+
+ ); +} diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx new file mode 100644 index 000000000..2fe1ecff6 --- /dev/null +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { + ActionBarPrimitive, + AssistantIf, + MessagePrimitive, + ThreadPrimitive, + useAssistantState, +} from "@assistant-ui/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +interface PublicThreadProps { + header?: ReactNode; + footer?: ReactNode; +} + +/** + * Read-only thread component for public chat viewing. + * No composer, no edit capabilities - just message display. + */ +export const PublicThread: FC = ({ header, footer }) => { + return ( + + + {header} + + + + {/* Spacer to ensure footer doesn't overlap last message */} +
+ + + {footer && ( +
+ {footer} +
+ )} + + ); +}; + +/** + * User avatar component with fallback to initials + */ +interface AuthorMetadata { + displayName: string | null; + avatarUrl: string | null; +} + +const UserAvatar: FC void }> = ({ + displayName, + avatarUrl, + hasError, + onError, +}) => { + const initials = displayName + ? displayName + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + : "U"; + + if (avatarUrl && !hasError) { + return ( + {displayName + ); + } + + return ( +
+ {initials} +
+ ); +}; + +const PublicUserMessage: FC = () => { + const metadata = useAssistantState(({ message }) => message?.metadata); + const author = metadata?.custom?.author as AuthorMetadata | undefined; + + return ( + +
+
+
+ +
+
+ {author && ( +
+ +
+ )} +
+
+ ); +}; + +const UserAvatarWithState: FC = ({ displayName, avatarUrl }) => { + const [hasError, setHasError] = useState(false); + return ( + setHasError(true)} + /> + ); +}; + +const PublicAssistantMessage: FC = () => { + return ( + +
+ +
+ +
+ +
+
+ ); +}; + +const PublicAssistantActionBar: FC = () => { + return ( + + + + message.isCopied}> + + + !message.isCopied}> + + + + + + ); +}; diff --git a/surfsense_web/hooks/use-public-chat-runtime.ts b/surfsense_web/hooks/use-public-chat-runtime.ts new file mode 100644 index 000000000..cc7e95fdc --- /dev/null +++ b/surfsense_web/hooks/use-public-chat-runtime.ts @@ -0,0 +1,53 @@ +"use client"; + +import { + type AppendMessage, + type ThreadMessageLike, + useExternalStoreRuntime, +} from "@assistant-ui/react"; +import { useCallback, useMemo } from "react"; +import type { GetPublicChatResponse, PublicChatMessage } from "@/contracts/types/public-chat.types"; + +interface UsePublicChatRuntimeOptions { + data: GetPublicChatResponse | undefined; +} + +/** + * Creates a read-only runtime for public chat viewing. + */ +export function usePublicChatRuntime({ data }: UsePublicChatRuntimeOptions) { + const messages = useMemo(() => data?.messages ?? [], [data?.messages]); + + // No-op - public chat is read-only + const onNew = useCallback(async (_message: AppendMessage) => {}, []); + + // Convert PublicChatMessage to ThreadMessageLike + const convertMessage = useCallback( + (msg: PublicChatMessage, idx: number): ThreadMessageLike => ({ + id: `public-msg-${idx}`, + role: msg.role as "user" | "assistant", + content: msg.content as ThreadMessageLike["content"], + createdAt: new Date(msg.created_at), + metadata: msg.author + ? { + custom: { + author: { + displayName: msg.author.display_name, + avatarUrl: msg.author.avatar_url, + }, + }, + } + : undefined, + }), + [] + ); + + const runtime = useExternalStoreRuntime({ + isRunning: false, + messages, + onNew, + convertMessage, + }); + + return runtime; +} diff --git a/surfsense_web/hooks/use-public-chat.ts b/surfsense_web/hooks/use-public-chat.ts new file mode 100644 index 000000000..83f34712e --- /dev/null +++ b/surfsense_web/hooks/use-public-chat.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import type { GetPublicChatResponse } from "@/contracts/types/public-chat.types"; +import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export function usePublicChat(shareToken: string) { + return useQuery({ + queryKey: cacheKeys.publicChat.byToken(shareToken), + queryFn: () => publicChatApiService.getPublicChat({ share_token: shareToken }), + enabled: !!shareToken, + staleTime: 30_000, + retry: false, + }); +} diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 72f2bbd54..19ddbce7b 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -75,4 +75,7 @@ export const cacheKeys = { comments: { byMessage: (messageId: number) => ["comments", "message", messageId] as const, }, + publicChat: { + byToken: (shareToken: string) => ["public-chat", shareToken] as const, + }, }; From ee65e1377f3fb9142b9b88291042f2aae096cdba Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 18:39:59 +0200 Subject: [PATCH 22/42] feat: improve public chat UI and shared components --- .vscode/settings.json | 3 +- .../app/services/public_chat_service.py | 15 ++- .../new-chat/[[...chat_id]]/page.tsx | 108 +---------------- .../atoms/chat/chat-thread-mutation.atoms.ts | 28 +++++ .../atoms/chat/current-thread.atom.ts | 4 + .../components/auth/sign-in-button.tsx | 88 ++++++++++++++ surfsense_web/components/homepage/navbar.tsx | 68 +---------- .../components/new-chat/chat-share-button.tsx | 114 ++++++++++++++++-- .../public-chat/public-chat-header.tsx | 34 ------ .../public-chat/public-chat-view.tsx | 52 ++++---- .../components/public-chat/public-thread.tsx | 12 +- .../hooks/use-public-chat-runtime.ts | 42 +++---- surfsense_web/lib/chat/message-utils.ts | 109 +++++++++++++++++ surfsense_web/lib/chat/thread-persistence.ts | 1 + 14 files changed, 403 insertions(+), 275 deletions(-) create mode 100644 surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts create mode 100644 surfsense_web/components/auth/sign-in-button.tsx delete mode 100644 surfsense_web/components/public-chat/public-chat-header.tsx create mode 100644 surfsense_web/lib/chat/message-utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index f134660b6..05bd30702 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "biome.configurationPath": "./surfsense_web/biome.json" + "biome.configurationPath": "./surfsense_web/biome.json", + "deepscan.ignoreConfirmWarning": true } \ No newline at end of file diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 08523c1f2..42a26c403 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -23,9 +23,18 @@ UI_TOOLS = { def strip_citations(text: str) -> str: - """Remove [citation:X] and [citation:doc-X] patterns from text.""" - text = re.sub(r"\[citation:(doc-)?\d+\]", "", text) - text = re.sub(r"\s+", " ", text) + """ + Remove [citation:X] and [citation:doc-X] patterns from text. + Preserves newlines to maintain markdown formatting. + """ + # Remove citation patterns (including Chinese brackets 【】) + text = re.sub(r"[\[【]citation:(doc-)?\d+[\]】]", "", text) + # Collapse multiple spaces/tabs (but NOT newlines) into single space + text = re.sub(r"[^\S\n]+", " ", text) + # Normalize excessive blank lines (3+ newlines → 2) + text = re.sub(r"\n{3,}", "\n\n", text) + # Clean up spaces around newlines + text = re.sub(r" *\n *", "\n", text) return text.strip() diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 59e7878c4..2af50f8e2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -44,6 +44,7 @@ import { looksLikePodcastRequest, setActivePodcastTaskId, } from "@/lib/chat/podcast-state"; +import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { appendMessage, type ChatVisibility, @@ -108,111 +109,6 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { return []; } -/** - * Zod schema for persisted attachment info - */ -const PersistedAttachmentSchema = z.object({ - id: z.string(), - name: z.string(), - type: z.string(), - contentType: z.string().optional(), - imageDataUrl: z.string().optional(), - extractedContent: z.string().optional(), -}); - -const AttachmentsPartSchema = z.object({ - type: z.literal("attachments"), - items: z.array(PersistedAttachmentSchema), -}); - -type PersistedAttachment = z.infer; - -/** - * Extract persisted attachments from message content (type-safe with Zod) - */ -function extractPersistedAttachments(content: unknown): PersistedAttachment[] { - if (!Array.isArray(content)) return []; - - for (const part of content) { - const result = AttachmentsPartSchema.safeParse(part); - if (result.success) { - return result.data.items; - } - } - - return []; -} - -/** - * Convert backend message to assistant-ui ThreadMessageLike format - * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps - * Restores attachments for user messages from persisted data - */ -function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { - let content: ThreadMessageLike["content"]; - - if (typeof msg.content === "string") { - content = [{ type: "text", text: msg.content }]; - } else if (Array.isArray(msg.content)) { - // Filter out custom metadata parts - they're handled separately - const filteredContent = msg.content.filter((part: unknown) => { - if (typeof part !== "object" || part === null || !("type" in part)) return true; - const partType = (part as { type: string }).type; - // Filter out thinking-steps, mentioned-documents, and attachments - return ( - partType !== "thinking-steps" && - partType !== "mentioned-documents" && - partType !== "attachments" - ); - }); - content = - filteredContent.length > 0 - ? (filteredContent as ThreadMessageLike["content"]) - : [{ type: "text", text: "" }]; - } else { - content = [{ type: "text", text: String(msg.content) }]; - } - - // Restore attachments for user messages - let attachments: ThreadMessageLike["attachments"]; - if (msg.role === "user") { - const persistedAttachments = extractPersistedAttachments(msg.content); - if (persistedAttachments.length > 0) { - attachments = persistedAttachments.map((att) => ({ - id: att.id, - name: att.name, - type: att.type as "document" | "image" | "file", - contentType: att.contentType || "application/octet-stream", - status: { type: "complete" as const }, - content: [], - // Custom fields for our ChatAttachment interface - imageDataUrl: att.imageDataUrl, - extractedContent: att.extractedContent, - })); - } - } - - // Build metadata.custom for author display in shared chats - const metadata = msg.author_id - ? { - custom: { - author: { - displayName: msg.author_display_name ?? null, - avatarUrl: msg.author_avatar_url ?? null, - }, - }, - } - : undefined; - - return { - id: `msg-${msg.id}`, - role: msg.role, - content, - createdAt: new Date(msg.created_at), - attachments, - metadata, - }; -} /** * Tools that should render custom UI in the chat. @@ -458,6 +354,8 @@ export default function NewChatPage() { visibility: currentThread?.visibility ?? null, hasComments: currentThread?.has_comments ?? false, addingCommentToMessageId: null, + publicShareEnabled: currentThread?.public_share_enabled ?? false, + publicShareToken: null, }); }, [currentThread, setCurrentThreadState]); diff --git a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts new file mode 100644 index 000000000..a844a45fb --- /dev/null +++ b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts @@ -0,0 +1,28 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + TogglePublicShareRequest, + TogglePublicShareResponse, +} from "@/contracts/types/chat-threads.types"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; + +export const togglePublicShareMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: TogglePublicShareRequest) => { + return chatThreadsApiService.togglePublicShare(request); + }, + onSuccess: (response: TogglePublicShareResponse) => { + if (response.enabled && response.share_token) { + const publicUrl = `${window.location.origin}/public/${response.share_token}`; + navigator.clipboard.writeText(publicUrl); + toast.success("Public link copied to clipboard", { + description: "Anyone with this link can view the chat", + }); + } else { + toast.success("Public sharing disabled"); + } + }, + onError: (error: Error) => { + console.error("Failed to toggle public share:", error); + toast.error("Failed to update public sharing"); + }, +})); diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index c19b2638c..7d6ccb0db 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -17,6 +17,8 @@ interface CurrentThreadState { visibility: ChatVisibility | null; hasComments: boolean; addingCommentToMessageId: number | null; + publicShareEnabled: boolean; + publicShareToken: string | null; } const initialState: CurrentThreadState = { @@ -24,6 +26,8 @@ const initialState: CurrentThreadState = { visibility: null, hasComments: false, addingCommentToMessageId: null, + publicShareEnabled: false, + publicShareToken: null, }; export const currentThreadAtom = atom(initialState); diff --git a/surfsense_web/components/auth/sign-in-button.tsx b/surfsense_web/components/auth/sign-in-button.tsx new file mode 100644 index 000000000..f7270df9a --- /dev/null +++ b/surfsense_web/components/auth/sign-in-button.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { motion } from "motion/react"; +import Link from "next/link"; +import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; +import { trackLoginAttempt } from "@/lib/posthog/events"; +import { cn } from "@/lib/utils"; + +// Official Google "G" logo with brand colors +const GoogleLogo = ({ className }: { className?: string }) => ( + + + + + + +); + +interface SignInButtonProps { + /** + * - "desktop": Hidden on mobile, visible on md+ (for navbar with separate mobile menu) + * - "mobile": Full width, always visible (for mobile menu) + * - "compact": Always visible, compact size (for headers) + */ + variant?: "desktop" | "mobile" | "compact"; +} + +export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => { + const isGoogleAuth = AUTH_TYPE === "GOOGLE"; + + const handleGoogleLogin = () => { + trackLoginAttempt("google"); + window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; + }; + + const getClassName = () => { + if (variant === "desktop") { + return isGoogleAuth + ? "hidden rounded-full bg-white px-5 py-2 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg md:flex dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50" + : "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"; + } + if (variant === "compact") { + return isGoogleAuth + ? "rounded-full bg-white px-4 py-1.5 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50" + : "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black"; + } + // mobile + return isGoogleAuth + ? "w-full rounded-lg bg-white px-8 py-2.5 text-neutral-700 shadow-md ring-1 ring-neutral-200/50 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 touch-manipulation" + : "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation"; + }; + + if (isGoogleAuth) { + return ( + + + Sign In + + ); + } + + return ( + + Sign In + + ); +}; diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index c83d3556a..4c66ac759 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -9,78 +9,12 @@ import { import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { SignInButton } from "@/components/auth/sign-in-button"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { useGithubStars } from "@/hooks/use-github-stars"; -import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; -import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; -// Official Google "G" logo with brand colors -const GoogleLogo = ({ className }: { className?: string }) => ( - - - - - - -); - -// Sign in button component that handles both Google OAuth and local auth -const SignInButton = ({ variant = "desktop" }: { variant?: "desktop" | "mobile" }) => { - const isGoogleAuth = AUTH_TYPE === "GOOGLE"; - - const handleGoogleLogin = () => { - trackLoginAttempt("google"); - window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; - }; - - if (isGoogleAuth) { - return ( - - - Sign In - - ); - } - - return ( - - Sign In - - ); -}; - export const Navbar = () => { const [isScrolled, setIsScrolled] = useState(false); diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index fcace2572..4e811779f 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -2,18 +2,15 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { User, Users } from "lucide-react"; +import { Globe, Link2, User, Users } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; +import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { - type ChatVisibility, - type ThreadRecord, - updateThreadVisibility, -} from "@/lib/chat/thread-persistence"; +import { type ChatVisibility, type ThreadRecord, updateThreadVisibility } from "@/lib/chat/thread-persistence"; import { cn } from "@/lib/utils"; interface ChatShareButtonProps { @@ -48,11 +45,19 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS // Use Jotai atom for visibility (single source of truth) const currentThreadState = useAtomValue(currentThreadAtom); + const setCurrentThreadState = useSetAtom(currentThreadAtom); const setThreadVisibility = useSetAtom(setThreadVisibilityAtom); + // Public share mutation + const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue( + togglePublicShareMutationAtom + ); + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; - const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it + const isPublicEnabled = + currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false; + const publicShareToken = currentThreadState.publicShareToken ?? null; const handleVisibilityChange = useCallback( async (newVisibility: ChatVisibility) => { @@ -87,12 +92,41 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility] ); + const handlePublicShareToggle = useCallback(async () => { + if (!thread) return; + + try { + const response = await togglePublicShare({ + thread_id: thread.id, + enabled: !isPublicEnabled, + }); + + // Update atom state with response + setCurrentThreadState((prev) => ({ + ...prev, + publicShareEnabled: response.enabled, + publicShareToken: response.share_token, + })); + } catch(error) { + console.error("Failed to toggle public share:", error); + } + }, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]); + + const handleCopyPublicLink = useCallback(async () => { + if (!publicShareToken) return; + + const publicUrl = `${window.location.origin}/public/${publicShareToken}`; + await navigator.clipboard.writeText(publicUrl); + toast.success("Public link copied to clipboard"); + }, [publicShareToken]); + // Don't show if no thread (new chat that hasn't been created yet) if (!thread) { return null; } - const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users; + const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users; + const buttonLabel = isPublicEnabled ? "Public" : currentVisibility === "PRIVATE" ? "Private" : "Shared"; return ( @@ -108,9 +142,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS )} > - - {currentVisibility === "PRIVATE" ? "Private" : "Shared"} - + {buttonLabel} @@ -124,6 +156,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS onCloseAutoFocus={(e) => e.preventDefault()} >
+ {/* Visibility Options */} {visibilityOptions.map((option) => { const isSelected = currentVisibility === option.value; const Icon = option.icon; @@ -166,6 +199,65 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS ); })} + + {/* Divider */} +
+ + {/* Public Share Option */} + + )} +
diff --git a/surfsense_web/components/public-chat/public-chat-header.tsx b/surfsense_web/components/public-chat/public-chat-header.tsx deleted file mode 100644 index 6f6e40a52..000000000 --- a/surfsense_web/components/public-chat/public-chat-header.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { formatDistanceToNow } from "date-fns"; -import Image from "next/image"; -import Link from "next/link"; - -interface PublicChatHeaderProps { - title: string; - createdAt: string; -} - -export function PublicChatHeader({ title, createdAt }: PublicChatHeaderProps) { - const timeAgo = formatDistanceToNow(new Date(createdAt), { addSuffix: true }); - - return ( -
-
-
- - SurfSense - -
-

{title}

-

{timeAgo}

-
-
-
-
- ); -} diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx index 1b7543712..8b21fede1 100644 --- a/surfsense_web/components/public-chat/public-chat-view.tsx +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -2,6 +2,7 @@ import { AssistantRuntimeProvider } from "@assistant-ui/react"; import { Loader2 } from "lucide-react"; +import { Navbar } from "@/components/homepage/navbar"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; @@ -9,7 +10,6 @@ import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { usePublicChat } from "@/hooks/use-public-chat"; import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime"; import { PublicChatFooter } from "./public-chat-footer"; -import { PublicChatHeader } from "./public-chat-header"; import { PublicThread } from "./public-thread"; interface PublicChatViewProps { @@ -22,37 +22,43 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) { if (isLoading) { return ( -
- -
+
+ +
+ +
+
); } if (error || !data) { return ( -
-

Chat not found

-

- This chat may have been removed or is no longer public. -

-
+
+ +
+

Chat not found

+

+ This chat may have been removed or is no longer public. +

+
+
); } return ( - - {/* Tool UIs for rendering tool results */} - - - - +
+ + + {/* Tool UIs for rendering tool results */} + + + + -
- } - footer={} - /> -
-
+
+ } /> +
+ +
); } diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 2fe1ecff6..e88e5aae7 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -12,10 +12,8 @@ import { type FC, type ReactNode, useState } from "react"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { cn } from "@/lib/utils"; interface PublicThreadProps { - header?: ReactNode; footer?: ReactNode; } @@ -23,7 +21,7 @@ interface PublicThreadProps { * Read-only thread component for public chat viewing. * No composer, no edit capabilities - just message display. */ -export const PublicThread: FC = ({ header, footer }) => { +export const PublicThread: FC = ({ footer }) => { return ( = ({ header, footer }) => { }} > - {header} - { return ( diff --git a/surfsense_web/hooks/use-public-chat-runtime.ts b/surfsense_web/hooks/use-public-chat-runtime.ts index cc7e95fdc..2e79e0e1b 100644 --- a/surfsense_web/hooks/use-public-chat-runtime.ts +++ b/surfsense_web/hooks/use-public-chat-runtime.ts @@ -1,17 +1,31 @@ "use client"; -import { - type AppendMessage, - type ThreadMessageLike, - useExternalStoreRuntime, -} from "@assistant-ui/react"; +import { type AppendMessage, useExternalStoreRuntime } from "@assistant-ui/react"; import { useCallback, useMemo } from "react"; import type { GetPublicChatResponse, PublicChatMessage } from "@/contracts/types/public-chat.types"; +import { convertToThreadMessage } from "@/lib/chat/message-utils"; +import type { MessageRecord } from "@/lib/chat/thread-persistence"; interface UsePublicChatRuntimeOptions { data: GetPublicChatResponse | undefined; } +/** + * Map PublicChatMessage to MessageRecord shape for reuse of convertToThreadMessage + */ +function toMessageRecord(msg: PublicChatMessage, idx: number): MessageRecord { + return { + id: idx, + thread_id: 0, + role: msg.role as "user" | "assistant" | "system", + content: msg.content, + created_at: msg.created_at, + author_id: msg.author ? "public" : null, + author_display_name: msg.author?.display_name ?? null, + author_avatar_url: msg.author?.avatar_url ?? null, + }; +} + /** * Creates a read-only runtime for public chat viewing. */ @@ -21,24 +35,8 @@ export function usePublicChatRuntime({ data }: UsePublicChatRuntimeOptions) { // No-op - public chat is read-only const onNew = useCallback(async (_message: AppendMessage) => {}, []); - // Convert PublicChatMessage to ThreadMessageLike const convertMessage = useCallback( - (msg: PublicChatMessage, idx: number): ThreadMessageLike => ({ - id: `public-msg-${idx}`, - role: msg.role as "user" | "assistant", - content: msg.content as ThreadMessageLike["content"], - createdAt: new Date(msg.created_at), - metadata: msg.author - ? { - custom: { - author: { - displayName: msg.author.display_name, - avatarUrl: msg.author.avatar_url, - }, - }, - } - : undefined, - }), + (msg: PublicChatMessage, idx: number) => convertToThreadMessage(toMessageRecord(msg, idx)), [] ); diff --git a/surfsense_web/lib/chat/message-utils.ts b/surfsense_web/lib/chat/message-utils.ts new file mode 100644 index 000000000..868ed28eb --- /dev/null +++ b/surfsense_web/lib/chat/message-utils.ts @@ -0,0 +1,109 @@ +import type { ThreadMessageLike } from "@assistant-ui/react"; +import { z } from "zod"; +import type { MessageRecord } from "./thread-persistence"; + +/** + * Zod schema for persisted attachment info + */ +const PersistedAttachmentSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + contentType: z.string().optional(), + imageDataUrl: z.string().optional(), + extractedContent: z.string().optional(), +}); + +const AttachmentsPartSchema = z.object({ + type: z.literal("attachments"), + items: z.array(PersistedAttachmentSchema), +}); + +type PersistedAttachment = z.infer; + +/** + * Extract persisted attachments from message content (type-safe with Zod) + */ +function extractPersistedAttachments(content: unknown): PersistedAttachment[] { + if (!Array.isArray(content)) return []; + + for (const part of content) { + const result = AttachmentsPartSchema.safeParse(part); + if (result.success) { + return result.data.items; + } + } + + return []; +} + +/** + * Convert backend message to assistant-ui ThreadMessageLike format + * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps + * Restores attachments for user messages from persisted data + */ +export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { + let content: ThreadMessageLike["content"]; + + if (typeof msg.content === "string") { + content = [{ type: "text", text: msg.content }]; + } else if (Array.isArray(msg.content)) { + // Filter out custom metadata parts - they're handled separately + const filteredContent = msg.content.filter((part: unknown) => { + if (typeof part !== "object" || part === null || !("type" in part)) return true; + const partType = (part as { type: string }).type; + // Filter out thinking-steps, mentioned-documents, and attachments + return ( + partType !== "thinking-steps" && + partType !== "mentioned-documents" && + partType !== "attachments" + ); + }); + content = + filteredContent.length > 0 + ? (filteredContent as ThreadMessageLike["content"]) + : [{ type: "text", text: "" }]; + } else { + content = [{ type: "text", text: String(msg.content) }]; + } + + // Restore attachments for user messages + let attachments: ThreadMessageLike["attachments"]; + if (msg.role === "user") { + const persistedAttachments = extractPersistedAttachments(msg.content); + if (persistedAttachments.length > 0) { + attachments = persistedAttachments.map((att) => ({ + id: att.id, + name: att.name, + type: att.type as "document" | "image" | "file", + contentType: att.contentType || "application/octet-stream", + status: { type: "complete" as const }, + content: [], + // Custom fields for our ChatAttachment interface + imageDataUrl: att.imageDataUrl, + extractedContent: att.extractedContent, + })); + } + } + + // Build metadata.custom for author display in shared chats + const metadata = msg.author_id + ? { + custom: { + author: { + displayName: msg.author_display_name ?? null, + avatarUrl: msg.author_avatar_url ?? null, + }, + }, + } + : undefined; + + return { + id: `msg-${msg.id}`, + role: msg.role, + content, + createdAt: new Date(msg.created_at), + attachments, + metadata, + }; +} diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index 08c08ba78..6990ff582 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -24,6 +24,7 @@ export interface ThreadRecord { created_at: string; updated_at: string; has_comments?: boolean; + public_share_enabled?: boolean; } export interface MessageRecord { From 271de96cce05d94b6c9ab082ef3c87735b008c1d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 20:10:03 +0200 Subject: [PATCH 23/42] fix: public chat copy link button and podcast access --- .../app/routes/podcasts_routes.py | 29 ++++++++++++------- surfsense_backend/app/schemas/new_chat.py | 1 + .../app/services/public_chat_service.py | 17 ++++++++--- .../new-chat/[[...chat_id]]/page.tsx | 2 +- .../components/new-chat/chat-share-button.tsx | 15 +++++++--- surfsense_web/lib/apis/base-api.service.ts | 2 +- surfsense_web/lib/chat/thread-persistence.ts | 1 + 7 files changed, 47 insertions(+), 20 deletions(-) diff --git a/surfsense_backend/app/routes/podcasts_routes.py b/surfsense_backend/app/routes/podcasts_routes.py index 467ef8d23..27970b707 100644 --- a/surfsense_backend/app/routes/podcasts_routes.py +++ b/surfsense_backend/app/routes/podcasts_routes.py @@ -84,12 +84,17 @@ async def read_podcasts( async def read_podcast( podcast_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User | None = Depends(current_optional_user), ): """ Get a specific podcast by ID. - Requires PODCASTS_READ permission for the search space. + + Access is allowed if: + - User is authenticated with PODCASTS_READ permission, OR + - Podcast belongs to a publicly shared thread """ + from app.services.public_chat_service import is_podcast_publicly_accessible + try: result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) podcast = result.scalars().first() @@ -100,14 +105,18 @@ async def read_podcast( detail="Podcast not found", ) - # Check permission for the search space - await check_permission( - session, - user, - podcast.search_space_id, - Permission.PODCASTS_READ.value, - "You don't have permission to read podcasts in this search space", - ) + is_public = await is_podcast_publicly_accessible(session, podcast_id) + + if not is_public: + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + await check_permission( + session, + user, + podcast.search_space_id, + Permission.PODCASTS_READ.value, + "You don't have permission to read podcasts in this search space", + ) return podcast except HTTPException as he: diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index ef2868495..5e9d44beb 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -96,6 +96,7 @@ class NewChatThreadRead(NewChatThreadBase, IDModel): visibility: ChatVisibility created_by_id: UUID | None = None public_share_enabled: bool = False + public_share_token: str | None = None created_at: datetime updated_at: datetime diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 42a26c403..62fd4f923 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -27,8 +27,8 @@ def strip_citations(text: str) -> str: Remove [citation:X] and [citation:doc-X] patterns from text. Preserves newlines to maintain markdown formatting. """ - # Remove citation patterns (including Chinese brackets 【】) - text = re.sub(r"[\[【]citation:(doc-)?\d+[\]】]", "", text) + # Remove citation patterns + text = re.sub(r"[\[【]\u200B?citation:(doc-)?\d+\u200B?[\]】]", "", text) # Collapse multiple spaces/tabs (but NOT newlines) into single space text = re.sub(r"[^\S\n]+", " ", text) # Normalize excessive blank lines (3+ newlines → 2) @@ -63,8 +63,17 @@ def sanitize_content_for_public(content: list | str | None) -> list: sanitized.append({"type": "text", "text": clean_text}) elif part_type == "tool-call": - if part.get("toolName") in UI_TOOLS: - sanitized.append(part) + tool_name = part.get("toolName") + if tool_name not in UI_TOOLS: + continue + + # Skip podcasts that are still processing (would cause auth errors) + if tool_name == "generate_podcast": + result = part.get("result", {}) + if result.get("status") in ("processing", "already_generating"): + continue + + sanitized.append(part) return sanitized diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 2af50f8e2..9b45d4d62 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -355,7 +355,7 @@ export default function NewChatPage() { hasComments: currentThread?.has_comments ?? false, addingCommentToMessageId: null, publicShareEnabled: currentThread?.public_share_enabled ?? false, - publicShareToken: null, + publicShareToken: currentThread?.public_share_token ?? null, }); }, [currentThread, setCurrentThreadState]); diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 4e811779f..2df363203 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -245,17 +245,24 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS

{isPublicEnabled && publicShareToken && ( - +
)} diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index a87d4deaf..b14818ac1 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -26,7 +26,7 @@ class BaseApiService { noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Prefixes that don't require auth (checked with startsWith) - noAuthPrefixes: string[] = ["/api/v1/public/"]; + noAuthPrefixes: string[] = ["/api/v1/public/", "/api/v1/podcasts/"]; // Use a getter to always read fresh token from localStorage // This ensures the token is always up-to-date after login/logout diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index 6990ff582..2188d9cec 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -25,6 +25,7 @@ export interface ThreadRecord { updated_at: string; has_comments?: boolean; public_share_enabled?: boolean; + public_share_token?: string | null; } export interface MessageRecord { From 1c98ba989daa785a0059bee74101c2fef2de2a37 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 21:01:36 +0200 Subject: [PATCH 24/42] fix: register clone task and sanitize cloned content --- surfsense_backend/app/celery_app.py | 1 + surfsense_backend/app/services/public_chat_service.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/celery_app.py b/surfsense_backend/app/celery_app.py index f7bea8cc3..b4869d23f 100644 --- a/surfsense_backend/app/celery_app.py +++ b/surfsense_backend/app/celery_app.py @@ -65,6 +65,7 @@ celery_app = Celery( "app.tasks.celery_tasks.schedule_checker_task", "app.tasks.celery_tasks.blocknote_migration_tasks", "app.tasks.celery_tasks.document_reindex_tasks", + "app.tasks.celery_tasks.clone_chat_tasks", ], ) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 62fd4f923..85f1a9572 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -253,6 +253,7 @@ async def clone_public_chat( Clone a public chat to user's account. Creates a new private thread with all messages and podcasts. + Citations are stripped since they reference the original user's documents. """ import copy @@ -291,7 +292,7 @@ async def clone_public_chat( podcast_id_map: dict[int, int] = {} for msg in sorted(source_thread.messages, key=lambda m: m.created_at): - new_content = copy.deepcopy(msg.content) + new_content = sanitize_content_for_public(msg.content) if isinstance(new_content, list): for part in new_content: From 0ad59edda0df0728697fd1145b9616bfb1dfc341 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 09:38:52 +0200 Subject: [PATCH 25/42] feat: auto-trigger clone after login redirect --- surfsense_web/app/(home)/login/page.tsx | 7 ++++ .../public-chat/public-chat-footer.tsx | 41 ++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index 7aade8427..a2dadd70c 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -27,6 +27,13 @@ function LoginContent() { const error = searchParams.get("error"); const message = searchParams.get("message"); const logout = searchParams.get("logout"); + const returnUrl = searchParams.get("returnUrl"); + + // Save returnUrl to localStorage so it persists through OAuth flows (e.g., Google) + // This is read by TokenHandler after successful authentication + if (returnUrl) { + localStorage.setItem("surfsense_redirect_path", decodeURIComponent(returnUrl)); + } // Show registration success message if (registered === "true") { diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx index 06e3d9975..80779b4e6 100644 --- a/surfsense_web/components/public-chat/public-chat-footer.tsx +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -1,8 +1,8 @@ "use client"; import { Copy, Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; @@ -14,17 +14,11 @@ interface PublicChatFooterProps { export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { const router = useRouter(); + const searchParams = useSearchParams(); const [isCloning, setIsCloning] = useState(false); + const hasAutoCloned = useRef(false); - const handleCopyAndContinue = async () => { - const token = getBearerToken(); - - if (!token) { - const returnUrl = encodeURIComponent(`/public/${shareToken}`); - router.push(`/login?returnUrl=${returnUrl}&action=clone`); - return; - } - + const triggerClone = useCallback(async () => { setIsCloning(true); try { @@ -43,6 +37,31 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { } finally { setIsCloning(false); } + }, [shareToken, router]); + + // Auto-trigger clone if user just logged in with action=clone + useEffect(() => { + const action = searchParams.get("action"); + const token = getBearerToken(); + + // Only auto-clone once, if authenticated and action=clone is present + if (action === "clone" && token && !hasAutoCloned.current && !isCloning) { + hasAutoCloned.current = true; + triggerClone(); + } + }, [searchParams, isCloning, triggerClone]); + + const handleCopyAndContinue = async () => { + const token = getBearerToken(); + + if (!token) { + // Include action=clone in the returnUrl so it persists after login + const returnUrl = encodeURIComponent(`/public/${shareToken}?action=clone`); + router.push(`/login?returnUrl=${returnUrl}`); + return; + } + + await triggerClone(); }; return ( From a42780a2ec1735cf437c58332b81cb35337e8240 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 09:39:03 +0200 Subject: [PATCH 26/42] feat: add chat_cloned notification types to inbox schema --- surfsense_web/contracts/types/inbox.types.ts | 58 +++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 0983bbc55..9269a6935 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -9,6 +9,8 @@ export const inboxItemTypeEnum = z.enum([ "connector_indexing", "document_processing", "new_mention", + "chat_cloned", + "chat_clone_failed", ]); /** @@ -88,6 +90,22 @@ export const newMentionMetadata = z.object({ content_preview: z.string(), }); +/** + * Chat cloned success metadata schema + */ +export const chatClonedMetadata = z.object({ + thread_id: z.number(), + search_space_id: z.number(), +}); + +/** + * Chat clone failed metadata schema + */ +export const chatCloneFailedMetadata = z.object({ + share_token: z.string(), + error: z.string(), +}); + /** * Union of all inbox item metadata types * Use this when the inbox item type is unknown @@ -96,6 +114,8 @@ export const inboxItemMetadata = z.union([ connectorIndexingMetadata, documentProcessingMetadata, newMentionMetadata, + chatClonedMetadata, + chatCloneFailedMetadata, baseInboxItemMetadata, ]); @@ -133,6 +153,16 @@ export const newMentionInboxItem = inboxItem.extend({ metadata: newMentionMetadata, }); +export const chatClonedInboxItem = inboxItem.extend({ + type: z.literal("chat_cloned"), + metadata: chatClonedMetadata, +}); + +export const chatCloneFailedInboxItem = inboxItem.extend({ + type: z.literal("chat_clone_failed"), + metadata: chatCloneFailedMetadata, +}); + // ============================================================================= // API Request/Response Schemas // ============================================================================= @@ -229,13 +259,27 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM return newMentionMetadata.safeParse(metadata).success; } +/** + * Type guard for ChatClonedMetadata + */ +export function isChatClonedMetadata(metadata: unknown): metadata is ChatClonedMetadata { + return chatClonedMetadata.safeParse(metadata).success; +} + +/** + * Type guard for ChatCloneFailedMetadata + */ +export function isChatCloneFailedMetadata(metadata: unknown): metadata is ChatCloneFailedMetadata { + return chatCloneFailedMetadata.safeParse(metadata).success; +} + /** * Safe metadata parser - returns typed metadata or null */ export function parseInboxItemMetadata( type: InboxItemTypeEnum, metadata: unknown -): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null { +): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | ChatClonedMetadata | ChatCloneFailedMetadata | null { switch (type) { case "connector_indexing": { const result = connectorIndexingMetadata.safeParse(metadata); @@ -249,6 +293,14 @@ export function parseInboxItemMetadata( const result = newMentionMetadata.safeParse(metadata); return result.success ? result.data : null; } + case "chat_cloned": { + const result = chatClonedMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + case "chat_clone_failed": { + const result = chatCloneFailedMetadata.safeParse(metadata); + return result.success ? result.data : null; + } default: return null; } @@ -265,11 +317,15 @@ export type BaseInboxItemMetadata = z.infer; export type ConnectorIndexingMetadata = z.infer; export type DocumentProcessingMetadata = z.infer; export type NewMentionMetadata = z.infer; +export type ChatClonedMetadata = z.infer; +export type ChatCloneFailedMetadata = z.infer; export type InboxItemMetadata = z.infer; export type InboxItem = z.infer; export type ConnectorIndexingInboxItem = z.infer; export type DocumentProcessingInboxItem = z.infer; export type NewMentionInboxItem = z.infer; +export type ChatClonedInboxItem = z.infer; +export type ChatCloneFailedInboxItem = z.infer; // API Request/Response types export type GetNotificationsRequest = z.infer; From 988847922f5b5157e676ed18ca4148fa527ce237 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 10:22:38 +0200 Subject: [PATCH 27/42] feat: clone notifications UI and PGlite resync on clone --- .../layout/ui/sidebar/InboxSidebar.tsx | 38 ++++++++++++++++++- .../public-chat/public-chat-footer.tsx | 3 ++ surfsense_web/lib/electric/client.ts | 10 +++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 810e3a22e..eacae6e49 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -7,6 +7,7 @@ import { Check, CheckCheck, CheckCircle2, + Copy, History, Inbox, LayoutGrid, @@ -43,6 +44,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { type ConnectorIndexingMetadata, + isChatClonedMetadata, + isChatCloneFailedMetadata, isConnectorIndexingMetadata, isNewMentionMetadata, type NewMentionMetadata, @@ -196,10 +199,15 @@ export function InboxSidebar({ [inboxItems] ); + // Status tab includes: connector indexing, document processing, chat clone notifications const statusItems = useMemo( () => inboxItems.filter( - (item) => item.type === "connector_indexing" || item.type === "document_processing" + (item) => + item.type === "connector_indexing" || + item.type === "document_processing" || + item.type === "chat_cloned" || + item.type === "chat_clone_failed" ), [inboxItems] ); @@ -320,7 +328,17 @@ export function InboxSidebar({ router.push(url); } } + } else if (item.type === "chat_cloned") { + // Navigate to the cloned chat + if (isChatClonedMetadata(item.metadata)) { + const { search_space_id, thread_id } = item.metadata; + const url = `/dashboard/${search_space_id}/new-chat/${thread_id}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } } + // chat_clone_failed: just mark as read, no navigation }, [markAsRead, router, onOpenChange, onCloseMobileSidebar] ); @@ -380,6 +398,24 @@ export function InboxSidebar({ ); } + // For chat cloned success, show green copy icon + if (item.type === "chat_cloned") { + return ( +
+ +
+ ); + } + + // For chat clone failed, show red alert icon + if (item.type === "chat_clone_failed") { + return ( +
+ +
+ ); + } + // For status items (connector/document), show status icons // Safely access status from metadata const metadata = item.metadata as Record; diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx index 80779b4e6..cc54d4150 100644 --- a/surfsense_web/components/public-chat/public-chat-footer.tsx +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -26,6 +26,9 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { share_token: shareToken, }); + // Force PGlite to resync notifications on next dashboard load + localStorage.setItem("surfsense_force_notif_resync", "true"); + toast.success("Copying chat to your account...", { description: "You'll be notified when it's ready.", }); diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 148da58ec..4e7ff87e7 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -274,6 +274,16 @@ export async function initElectric(userId: string): Promise { CREATE INDEX IF NOT EXISTS idx_new_chat_messages_created_at ON new_chat_messages(created_at); `); + // Force resync notifications if flagged (e.g., after clone from public page) + if ( + typeof window !== "undefined" && + localStorage.getItem("surfsense_force_notif_resync") === "true" + ) { + console.log("[Electric] Force resync flag detected, clearing notifications table"); + await db.exec("DELETE FROM notifications"); + localStorage.removeItem("surfsense_force_notif_resync"); + } + const electricUrl = getElectricUrl(); // STEP 4: Create the client wrapper From 24472c0ea6137b3e787a92c584978f265cca2d8e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 10:50:37 +0200 Subject: [PATCH 28/42] fix: preserve author info in cloned chats and force PGlite resync after clone --- .../[search_space_id]/new-chat/[[...chat_id]]/page.tsx | 10 ++++++++-- surfsense_web/hooks/use-inbox.ts | 9 +++++++++ surfsense_web/lib/electric/client.ts | 10 ---------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9b45d4d62..a56cd84ce 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -187,6 +187,12 @@ export default function NewChatPage() { ? membersData?.find((m) => m.user_id === msg.author_id) : null; + // Preserve existing author info if member lookup fails (e.g., cloned chats) + const existingMsg = prev.find((m) => m.id === `msg-${msg.id}`); + const existingAuthor = existingMsg?.metadata?.custom?.author as + | { displayName?: string | null; avatarUrl?: string | null } + | undefined; + return convertToThreadMessage({ id: msg.id, thread_id: msg.thread_id, @@ -194,8 +200,8 @@ export default function NewChatPage() { content: msg.content, author_id: msg.author_id, created_at: msg.created_at, - author_display_name: member?.user_display_name ?? null, - author_avatar_url: member?.user_avatar_url ?? null, + author_display_name: member?.user_display_name ?? existingAuthor?.displayName ?? null, + author_avatar_url: member?.user_avatar_url ?? existingAuthor?.avatarUrl ?? null, }); }); }); diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 4c26ddcb9..656de18a8 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -119,6 +119,15 @@ export function useInbox( async function startSync() { try { + // Check for force resync flag (e.g., after clone from public page) + if (localStorage.getItem("surfsense_force_notif_resync") === "true") { + console.log("[useInbox] Force resync flag detected, clearing notifications"); + await client.db.exec("DELETE FROM notifications"); + localStorage.removeItem("surfsense_force_notif_resync"); + // Reset sync key to force a fresh sync + userSyncKeyRef.current = null; + } + const cutoffDate = getSyncCutoffDate(); const userSyncKey = `inbox_${userId}_${cutoffDate}`; diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 4e7ff87e7..148da58ec 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -274,16 +274,6 @@ export async function initElectric(userId: string): Promise { CREATE INDEX IF NOT EXISTS idx_new_chat_messages_created_at ON new_chat_messages(created_at); `); - // Force resync notifications if flagged (e.g., after clone from public page) - if ( - typeof window !== "undefined" && - localStorage.getItem("surfsense_force_notif_resync") === "true" - ) { - console.log("[Electric] Force resync flag detected, clearing notifications table"); - await db.exec("DELETE FROM notifications"); - localStorage.removeItem("surfsense_force_notif_resync"); - } - const electricUrl = getElectricUrl(); // STEP 4: Create the client wrapper From bd921a8ec8d2b8e297989c3f8d63182ba2b79725 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 10:58:03 +0200 Subject: [PATCH 29/42] fix: handle abort error in useGithubStars hook --- surfsense_web/hooks/use-github-stars.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/surfsense_web/hooks/use-github-stars.ts b/surfsense_web/hooks/use-github-stars.ts index a4d4f80fd..aa2bad1b9 100644 --- a/surfsense_web/hooks/use-github-stars.ts +++ b/surfsense_web/hooks/use-github-stars.ts @@ -25,6 +25,10 @@ export const useGithubStars = () => { setStars(data?.stargazers_count); } catch (err) { + // Ignore abort errors (expected on unmount) + if (err instanceof Error && err.name === "AbortError") { + return; + } if (err instanceof Error) { console.error("Error fetching stars:", err); setError(err.message); @@ -37,7 +41,7 @@ export const useGithubStars = () => { getStars(); return () => { - abortController.abort(); + abortController.abort("Component unmounted"); }; }, []); From 6091e070f333f718dfa4b044829b21e3e41823be Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 11:11:08 +0200 Subject: [PATCH 30/42] chore: remove unused import and restrict noAuthPrefixes --- surfsense_backend/app/services/public_chat_service.py | 2 -- surfsense_web/lib/apis/base-api.service.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 85f1a9572..a6434a611 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -255,8 +255,6 @@ async def clone_public_chat( Creates a new private thread with all messages and podcasts. Citations are stripped since they reference the original user's documents. """ - import copy - from app.db import ( ChatVisibility, NewChatMessage, diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index b14818ac1..a87d4deaf 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -26,7 +26,7 @@ class BaseApiService { noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Prefixes that don't require auth (checked with startsWith) - noAuthPrefixes: string[] = ["/api/v1/public/", "/api/v1/podcasts/"]; + noAuthPrefixes: string[] = ["/api/v1/public/"]; // Use a getter to always read fresh token from localStorage // This ensures the token is always up-to-date after login/logout From ba304be9777c12ef93fc5eb80b257d0d6e3e1ec2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:28:30 +0530 Subject: [PATCH 31/42] fix: remove message from loading UI --- surfsense_web/app/(home)/login/page.tsx | 2 +- surfsense_web/app/auth/callback/loading.tsx | 5 +-- .../[search_space_id]/client-layout.tsx | 6 +-- surfsense_web/app/dashboard/layout.tsx | 4 +- surfsense_web/app/dashboard/loading.tsx | 5 +-- surfsense_web/app/dashboard/page.tsx | 2 +- surfsense_web/atoms/ui/loading.atoms.ts | 19 ++------- surfsense_web/components/TokenHandler.tsx | 4 +- .../components/providers/ElectricProvider.tsx | 4 +- .../providers/GlobalLoadingProvider.tsx | 36 +++-------------- surfsense_web/hooks/use-global-loading.ts | 39 +++++++------------ surfsense_web/messages/en.json | 9 +---- surfsense_web/messages/zh.json | 9 +---- 13 files changed, 34 insertions(+), 110 deletions(-) diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index 0dc9c445f..a3ef7cd8f 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -93,7 +93,7 @@ function LoginContent() { }, [searchParams, t, tCommon]); // Use global loading screen for auth type determination - spinner animation won't reset - useGlobalLoadingEffect(isLoading, tCommon("loading"), "login"); + useGlobalLoadingEffect(isLoading); // Show nothing while loading - the GlobalLoadingProvider handles the loading UI if (isLoading) { diff --git a/surfsense_web/app/auth/callback/loading.tsx b/surfsense_web/app/auth/callback/loading.tsx index 0c94e1ee0..f12b3847d 100644 --- a/surfsense_web/app/auth/callback/loading.tsx +++ b/surfsense_web/app/auth/callback/loading.tsx @@ -1,13 +1,10 @@ "use client"; -import { useTranslations } from "next-intl"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; export default function AuthCallbackLoading() { - const t = useTranslations("auth"); - // Use global loading - spinner animation won't reset when page transitions - useGlobalLoadingEffect(true, t("processing_authentication"), "default"); + useGlobalLoadingEffect(true); // Return null - the GlobalLoadingProvider handles the loading UI return null; diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index e6730d8d1..8418d4719 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -154,11 +154,7 @@ export function DashboardClientLayout({ isAutoConfiguring; // Use global loading screen - spinner animation won't reset - useGlobalLoadingEffect( - shouldShowLoading, - isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"), - "default" - ); + useGlobalLoadingEffect(shouldShowLoading); if (shouldShowLoading) { return null; diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx index 889b823d6..4a32c2147 100644 --- a/surfsense_web/app/dashboard/layout.tsx +++ b/surfsense_web/app/dashboard/layout.tsx @@ -1,6 +1,5 @@ "use client"; -import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; @@ -10,11 +9,10 @@ interface DashboardLayoutProps { } export default function DashboardLayout({ children }: DashboardLayoutProps) { - const t = useTranslations("dashboard"); const [isCheckingAuth, setIsCheckingAuth] = useState(true); // Use the global loading screen - spinner animation won't reset - useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default"); + useGlobalLoadingEffect(isCheckingAuth); useEffect(() => { // Check if user is authenticated diff --git a/surfsense_web/app/dashboard/loading.tsx b/surfsense_web/app/dashboard/loading.tsx index 2eee93651..ca6b05de0 100644 --- a/surfsense_web/app/dashboard/loading.tsx +++ b/surfsense_web/app/dashboard/loading.tsx @@ -1,13 +1,10 @@ "use client"; -import { useTranslations } from "next-intl"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; export default function DashboardLoading() { - const t = useTranslations("common"); - // Use global loading - spinner animation won't reset when page transitions - useGlobalLoadingEffect(true, t("loading"), "default"); + useGlobalLoadingEffect(true); // Return null - the GlobalLoadingProvider handles the loading UI return null; diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 504d172c3..2bd8f4462 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -106,7 +106,7 @@ export default function DashboardPage() { const shouldShowLoading = isLoading || searchSpaces.length > 0; // Use global loading screen - spinner animation won't reset - useGlobalLoadingEffect(shouldShowLoading, t("fetching_spaces"), "default"); + useGlobalLoadingEffect(shouldShowLoading); if (error) return ; diff --git a/surfsense_web/atoms/ui/loading.atoms.ts b/surfsense_web/atoms/ui/loading.atoms.ts index f10d9247b..ca37e1cdc 100644 --- a/surfsense_web/atoms/ui/loading.atoms.ts +++ b/surfsense_web/atoms/ui/loading.atoms.ts @@ -2,29 +2,18 @@ import { atom } from "jotai"; interface GlobalLoadingState { isLoading: boolean; - message?: string; - variant: "login" | "default"; } export const globalLoadingAtom = atom({ isLoading: false, - message: undefined, - variant: "default", }); // Helper atom for showing global loading -export const showGlobalLoadingAtom = atom( - null, - ( - get, - set, - { message, variant = "default" }: { message?: string; variant?: "login" | "default" } - ) => { - set(globalLoadingAtom, { isLoading: true, message, variant }); - } -); +export const showGlobalLoadingAtom = atom(null, (get, set) => { + set(globalLoadingAtom, { isLoading: true }); +}); // Helper atom for hiding global loading export const hideGlobalLoadingAtom = atom(null, (get, set) => { - set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" }); + set(globalLoadingAtom, { isLoading: false }); }); diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index 35408c1b2..e3295df7c 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -1,7 +1,6 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { useTranslations } from "next-intl"; import { useEffect } from "react"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils"; @@ -27,11 +26,10 @@ const TokenHandler = ({ tokenParamName = "token", storageKey = "surfsense_bearer_token", }: TokenHandlerProps) => { - const t = useTranslations("auth"); const searchParams = useSearchParams(); // Always show loading for this component - spinner animation won't reset - useGlobalLoadingEffect(true, t("processing_authentication"), "default"); + useGlobalLoadingEffect(true); useEffect(() => { // Only run on client-side diff --git a/surfsense_web/components/providers/ElectricProvider.tsx b/surfsense_web/components/providers/ElectricProvider.tsx index 07d736c64..4aa83b304 100644 --- a/surfsense_web/components/providers/ElectricProvider.tsx +++ b/surfsense_web/components/providers/ElectricProvider.tsx @@ -1,7 +1,6 @@ "use client"; import { useAtomValue } from "jotai"; -import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; @@ -30,7 +29,6 @@ interface ElectricProviderProps { * 5. Provides client via context - hooks should use useElectricClient() */ export function ElectricProvider({ children }: ElectricProviderProps) { - const t = useTranslations("common"); const [electricClient, setElectricClient] = useState(null); const [error, setError] = useState(null); const { @@ -117,7 +115,7 @@ export function ElectricProvider({ children }: ElectricProviderProps) { const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error; // Use global loading hook with ownership tracking - prevents flash during transitions - useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default"); + useGlobalLoadingEffect(shouldShowLoading); // For non-authenticated pages (like landing page), render immediately with null context // Also render immediately if user query failed (e.g., token expired) diff --git a/surfsense_web/components/providers/GlobalLoadingProvider.tsx b/surfsense_web/components/providers/GlobalLoadingProvider.tsx index db66b9a64..08c888954 100644 --- a/surfsense_web/components/providers/GlobalLoadingProvider.tsx +++ b/surfsense_web/components/providers/GlobalLoadingProvider.tsx @@ -3,9 +3,7 @@ import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { AmbientBackground } from "@/app/(home)/login/AmbientBackground"; import { globalLoadingAtom } from "@/atoms/ui/loading.atoms"; -import { Logo } from "@/components/Logo"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; @@ -18,7 +16,7 @@ import { cn } from "@/lib/utils"; */ export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false); - const { isLoading, message, variant } = useAtomValue(globalLoadingAtom); + const { isLoading } = useAtomValue(globalLoadingAtom); useEffect(() => { setMounted(true); @@ -36,35 +34,11 @@ export function GlobalLoadingProvider({ children }: { children: React.ReactNode )} aria-hidden={!isLoading} > - {variant === "login" ? ( -
- -
- -
-
- {/* Spinner is always mounted, animation never resets */} - -
- - {message} - -
-
+
+
+
- ) : ( -
-
-
- {/* Spinner is always mounted, animation never resets */} - -
- - {message} - -
-
- )} +
); diff --git a/surfsense_web/hooks/use-global-loading.ts b/surfsense_web/hooks/use-global-loading.ts index baaa1f089..fee8ae18e 100644 --- a/surfsense_web/hooks/use-global-loading.ts +++ b/surfsense_web/hooks/use-global-loading.ts @@ -20,21 +20,18 @@ let pendingHideTimeout: ReturnType | null = null; export function useGlobalLoading() { const [loading, setLoading] = useAtom(globalLoadingAtom); - const show = useCallback( - (message?: string, variant: "login" | "default" = "default") => { - // Cancel any pending hide - new loading request takes over - if (pendingHideTimeout) { - clearTimeout(pendingHideTimeout); - pendingHideTimeout = null; - } + const show = useCallback(() => { + // Cancel any pending hide - new loading request takes over + if (pendingHideTimeout) { + clearTimeout(pendingHideTimeout); + pendingHideTimeout = null; + } - const id = ++loadingIdCounter; - currentLoadingId = id; - setLoading({ isLoading: true, message, variant }); - return id; - }, - [setLoading] - ); + const id = ++loadingIdCounter; + currentLoadingId = id; + setLoading({ isLoading: true }); + return id; + }, [setLoading]); const hide = useCallback( (id?: number) => { @@ -50,7 +47,7 @@ export function useGlobalLoading() { // Double-check we're still the current loading after the delay if (id === undefined || id === currentLoadingId) { currentLoadingId = null; - setLoading({ isLoading: false, message: undefined, variant: "default" }); + setLoading({ isLoading: false }); } pendingHideTimeout = null; }, 50); // Small delay to allow next component to mount and show loading @@ -70,27 +67,21 @@ export function useGlobalLoading() { * transition loading states (e.g., layout → page). * * @param shouldShow - Whether the loading screen should be visible - * @param message - Optional message to display - * @param variant - Visual style variant ("login" or "default") */ -export function useGlobalLoadingEffect( - shouldShow: boolean, - message?: string, - variant: "login" | "default" = "default" -) { +export function useGlobalLoadingEffect(shouldShow: boolean) { const { show, hide } = useGlobalLoading(); const loadingIdRef = useRef(null); useEffect(() => { if (shouldShow) { // Show loading and store the ID - loadingIdRef.current = show(message, variant); + loadingIdRef.current = show(); } else if (loadingIdRef.current !== null) { // Only hide if we were the ones showing loading hide(loadingIdRef.current); loadingIdRef.current = null; } - }, [shouldShow, message, variant, show, hide]); + }, [shouldShow, show, hide]); // Cleanup on unmount - only hide if we're still the active loading useEffect(() => { diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index f14c73ddc..578bb9002 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -2,8 +2,6 @@ "common": { "app_name": "SurfSense", "welcome": "Welcome", - "loading": "Loading", - "initializing": "Initializing", "save": "Save", "cancel": "Cancel", "delete": "Delete", @@ -80,8 +78,7 @@ "passwords_no_match_desc": "The passwords you entered do not match", "creating_account": "Creating your account", "creating_account_btn": "Creating account", - "redirecting_login": "Redirecting to login page", - "processing_authentication": "Processing authentication" + "redirecting_login": "Redirecting to login page" }, "searchSpace": { "create_title": "Create Search Space", @@ -146,10 +143,7 @@ "api_keys": "API Keys", "profile": "Profile", "loading_dashboard": "Loading Dashboard", - "checking_auth": "Checking authentication", "loading_config": "Loading Configuration", - "checking_llm_prefs": "Checking your LLM preferences", - "setting_up_ai": "Setting up AI", "config_error": "Configuration Error", "failed_load_llm_config": "Failed to load your LLM configuration", "error_loading_chats": "Error loading chats", @@ -171,7 +165,6 @@ "create_search_space": "Create Search Space", "add_new_search_space": "Add New Search Space", "loading": "Loading", - "fetching_spaces": "Fetching your search spaces", "may_take_moment": "This may take a moment", "error": "Error", "something_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 6838b0f52..9bbbe1ecf 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -2,8 +2,6 @@ "common": { "app_name": "SurfSense", "welcome": "欢迎", - "loading": "加载中...", - "initializing": "正在初始化", "save": "保存", "cancel": "取消", "delete": "删除", @@ -80,8 +78,7 @@ "passwords_no_match_desc": "您输入的密码不一致", "creating_account": "正在创建您的账户", "creating_account_btn": "创建中", - "redirecting_login": "正在跳转到登录页面", - "processing_authentication": "正在处理身份验证" + "redirecting_login": "正在跳转到登录页面" }, "searchSpace": { "create_title": "创建搜索空间", @@ -131,10 +128,7 @@ "api_keys": "API 密钥", "profile": "个人资料", "loading_dashboard": "正在加载仪表盘", - "checking_auth": "正在检查身份验证", "loading_config": "正在加载配置", - "checking_llm_prefs": "正在检查您的 LLM 偏好设置", - "setting_up_ai": "正在设置 AI", "config_error": "配置错误", "failed_load_llm_config": "无法加载您的 LLM 配置", "error_loading_chats": "加载对话失败", @@ -156,7 +150,6 @@ "create_search_space": "创建搜索空间", "add_new_search_space": "添加新的搜索空间", "loading": "加载中", - "fetching_spaces": "正在获取您的搜索空间", "may_take_moment": "这可能需要一些时间", "error": "错误", "something_wrong": "出现错误", From b148731fda4f3c6098ce4ec6c000ca4ab9609d06 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:32:05 +0530 Subject: [PATCH 32/42] feat(sidebar): enhance chat sections layout and functionality with improved height management --- .../layout/providers/LayoutDataProvider.tsx | 6 +- .../components/layout/ui/sidebar/Sidebar.tsx | 213 +++++++++--------- .../layout/ui/sidebar/SidebarSection.tsx | 28 ++- 3 files changed, 139 insertions(+), 108 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 702014050..0baf1dcfa 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -87,10 +87,10 @@ export function LayoutDataProvider({ enabled: !!searchSpaceId, }); - // Fetch threads + // Fetch threads (40 total to allow up to 20 per section - shared/private) const { data: threadsData } = useQuery({ - queryKey: ["threads", searchSpaceId, { limit: 4 }], - queryFn: () => fetchThreads(Number(searchSpaceId), 4), + queryKey: ["threads", searchSpaceId, { limit: 40 }], + queryFn: () => fetchThreads(Number(searchSpaceId), 40), enabled: !!searchSpaceId, }); diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index d05f21096..6db23a5c5 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -3,7 +3,6 @@ import { FolderOpen, MessageSquare, PenSquare } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; @@ -121,101 +120,109 @@ export function Sidebar({ )} - {/* Scrollable content */} - - {isCollapsed ? ( -
- {(chats.length > 0 || sharedChats.length > 0) && ( - - - - - - {t("chats")} ({chats.length + sharedChats.length}) - - + {/* Chat sections - fills available space */} + {isCollapsed ? ( +
+ {(chats.length > 0 || sharedChats.length > 0) && ( + + + + + + {t("chats")} ({chats.length + sharedChats.length}) + + + )} +
+ ) : ( +
+ {/* Shared Chats Section - takes half the space */} + + + + + + {t("view_all_shared_chats") || "View all shared chats"} + + + ) : undefined + } + > + {sharedChats.length > 0 ? ( +
+
4 ? 'pb-8' : ''}`}> + {sharedChats.slice(0, 20).map((chat) => ( + onChatSelect(chat)} + onArchive={() => onChatArchive?.(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))} +
+ {/* Gradient fade indicator when more than 4 items */} + {sharedChats.length > 4 && ( +
+ )} +
+ ) : ( +

{t("no_shared_chats")}

)} -
- ) : ( -
- {/* Shared Chats Section */} - - - - - - {t("view_all_shared_chats") || "View all shared chats"} - - - ) : undefined - } - > - {sharedChats.length > 0 ? ( -
- {sharedChats.map((chat) => ( - onChatSelect(chat)} - onArchive={() => onChatArchive?.(chat)} - onDelete={() => onChatDelete?.(chat)} - /> - ))} -
- ) : ( -

{t("no_shared_chats")}

- )} -
+ - {/* Private Chats Section */} - - - - - - {t("view_all_private_chats") || "View all private chats"} - - - ) : undefined - } - > - {chats.length > 0 ? ( -
- {chats.map((chat) => ( + {/* Private Chats Section - takes half the space */} + + + + + + {t("view_all_private_chats") || "View all private chats"} + + + ) : undefined + } + > + {chats.length > 0 ? ( +
+
4 ? 'pb-8' : ''}`}> + {chats.slice(0, 20).map((chat) => ( ))}
- ) : ( -

{t("no_chats")}

- )} - -
- )} - + {/* Gradient fade indicator when more than 4 items */} + {chats.length > 4 && ( +
+ )} +
+ ) : ( +

{t("no_chats")}

+ )} +
+
+ )} {/* Footer */}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index 0ceafc113..e296ed3d4 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -11,6 +11,8 @@ interface SidebarSectionProps { children: React.ReactNode; action?: React.ReactNode; persistentAction?: React.ReactNode; + className?: string; + fillHeight?: boolean; } export function SidebarSection({ @@ -19,12 +21,22 @@ export function SidebarSection({ children, action, persistentAction, + className, + fillHeight = false, }: SidebarSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); return ( - -
+ +
- -
{children}
+ +
+ {children} +
); From 3604a0bbf3ccfcdf9d6495b79e44c0654369e2eb Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:34:35 +0530 Subject: [PATCH 33/42] chore: ran frontend linting --- .../components/layout/ui/sidebar/Sidebar.tsx | 8 ++++-- .../layout/ui/sidebar/SidebarSection.tsx | 26 +++++++------------ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 6db23a5c5..4a587cd58 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -171,7 +171,9 @@ export function Sidebar({ > {sharedChats.length > 0 ? (
-
4 ? 'pb-8' : ''}`}> +
4 ? "pb-8" : ""}`} + > {sharedChats.slice(0, 20).map((chat) => ( {chats.length > 0 ? (
-
4 ? 'pb-8' : ''}`}> +
4 ? "pb-8" : ""}`} + > {chats.slice(0, 20).map((chat) => (
@@ -60,14 +56,12 @@ export function SidebarSection({ )}
- -
+ +
{children}
From 3c40c6e3659b0de848a28d845444ce26e41b842c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 13:33:36 +0200 Subject: [PATCH 34/42] feat: add clone tracking and history bootstrap for cloned chats --- .vscode/settings.json | 3 +- .../versions/81_add_public_chat_features.py | 105 ++++++++++++++++++ .../81_add_public_share_to_chat_threads.py | 66 ----------- surfsense_backend/app/db.py | 19 ++++ .../app/routes/new_chat_routes.py | 2 + .../app/services/public_chat_service.py | 4 + .../app/tasks/chat/stream_new_chat.py | 21 +++- surfsense_backend/app/utils/content_utils.py | 75 +++++++++++++ 8 files changed, 225 insertions(+), 70 deletions(-) create mode 100644 surfsense_backend/alembic/versions/81_add_public_chat_features.py delete mode 100644 surfsense_backend/alembic/versions/81_add_public_share_to_chat_threads.py create mode 100644 surfsense_backend/app/utils/content_utils.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 05bd30702..f134660b6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,3 @@ { - "biome.configurationPath": "./surfsense_web/biome.json", - "deepscan.ignoreConfirmWarning": true + "biome.configurationPath": "./surfsense_web/biome.json" } \ No newline at end of file diff --git a/surfsense_backend/alembic/versions/81_add_public_chat_features.py b/surfsense_backend/alembic/versions/81_add_public_chat_features.py new file mode 100644 index 000000000..ab73b06bb --- /dev/null +++ b/surfsense_backend/alembic/versions/81_add_public_chat_features.py @@ -0,0 +1,105 @@ +"""Add public chat sharing and cloning features to new_chat_threads + +Revision ID: 81 +Revises: 80 +Create Date: 2026-01-23 + +Adds columns for: +1. Public sharing via tokenized URLs (public_share_token, public_share_enabled) +2. Clone tracking for audit (cloned_from_thread_id, cloned_at) +3. History bootstrap flag for cloned chats (needs_history_bootstrap) +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "81" +down_revision: str | None = "80" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add public sharing and cloning columns to new_chat_threads.""" + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS public_share_token VARCHAR(64); + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS public_share_enabled BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_token + ON new_chat_threads(public_share_token) + WHERE public_share_token IS NOT NULL; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_enabled + ON new_chat_threads(public_share_enabled) + WHERE public_share_enabled = TRUE; + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS cloned_from_thread_id INTEGER + REFERENCES new_chat_threads(id) ON DELETE SET NULL; + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS cloned_at TIMESTAMP WITH TIME ZONE; + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS needs_history_bootstrap BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_cloned_from_thread_id + ON new_chat_threads(cloned_from_thread_id) + WHERE cloned_from_thread_id IS NOT NULL; + """ + ) + + +def downgrade() -> None: + """Remove public sharing and cloning columns from new_chat_threads.""" + + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_cloned_from_thread_id") + op.execute( + "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS needs_history_bootstrap" + ) + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS cloned_at") + op.execute( + "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS cloned_from_thread_id" + ) + + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_enabled") + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_token") + op.execute( + "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_enabled" + ) + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_token") diff --git a/surfsense_backend/alembic/versions/81_add_public_share_to_chat_threads.py b/surfsense_backend/alembic/versions/81_add_public_share_to_chat_threads.py deleted file mode 100644 index 33e1a88e9..000000000 --- a/surfsense_backend/alembic/versions/81_add_public_share_to_chat_threads.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Add public sharing columns to new_chat_threads - -Revision ID: 81 -Revises: 80 -Create Date: 2026-01-23 - -Adds public_share_token and public_share_enabled columns to enable -public sharing of chat threads via secure tokenized URLs. -""" - -from collections.abc import Sequence - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "81" -down_revision: str | None = "80" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Add public sharing columns to new_chat_threads.""" - # Add public_share_token column - op.execute( - """ - ALTER TABLE new_chat_threads - ADD COLUMN IF NOT EXISTS public_share_token VARCHAR(64); - """ - ) - - # Add public_share_enabled column with default false - op.execute( - """ - ALTER TABLE new_chat_threads - ADD COLUMN IF NOT EXISTS public_share_enabled BOOLEAN NOT NULL DEFAULT FALSE; - """ - ) - - # Add unique partial index on public_share_token (only non-null values) - op.execute( - """ - CREATE UNIQUE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_token - ON new_chat_threads(public_share_token) - WHERE public_share_token IS NOT NULL; - """ - ) - - # Add partial index on public_share_enabled for fast public chat queries - op.execute( - """ - CREATE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_enabled - ON new_chat_threads(public_share_enabled) - WHERE public_share_enabled = TRUE; - """ - ) - - -def downgrade() -> None: - """Remove public sharing columns from new_chat_threads.""" - op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_enabled") - op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_token") - op.execute( - "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_enabled" - ) - op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_token") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index e3b077ff0..0182d2c53 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -412,6 +412,25 @@ class NewChatThread(BaseModel, TimestampMixin): server_default="false", ) + # Clone tracking - for audit and history bootstrap + cloned_from_thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + cloned_at = Column( + TIMESTAMP(timezone=True), + nullable=True, + ) + # Flag to bootstrap LangGraph checkpointer with DB messages on first message + needs_history_bootstrap = Column( + Boolean, + nullable=False, + default=False, + server_default="false", + ) + # Relationships search_space = relationship("SearchSpace", back_populates="new_chat_threads") created_by = relationship("User", back_populates="new_chat_threads") diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 4571e9051..db371a81c 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -1027,6 +1027,7 @@ async def handle_new_chat( attachments=request.attachments, mentioned_document_ids=request.mentioned_document_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, + needs_history_bootstrap=thread.needs_history_bootstrap, ), media_type="text/event-stream", headers={ @@ -1254,6 +1255,7 @@ async def regenerate_response( mentioned_document_ids=request.mentioned_document_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, checkpoint_id=target_checkpoint_id, + needs_history_bootstrap=thread.needs_history_bootstrap, ): yield chunk # If we get here, streaming completed successfully diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index a6434a611..7c3b89af9 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -4,6 +4,7 @@ Service layer for public chat sharing and cloning. import re import secrets +from datetime import UTC, datetime from uuid import UUID from fastapi import HTTPException @@ -283,6 +284,9 @@ async def clone_public_chat( search_space_id=target_search_space_id, created_by_id=user_id, public_share_enabled=False, + cloned_from_thread_id=source_thread.id, + cloned_at=datetime.now(UTC), + needs_history_bootstrap=True, ) session.add(new_thread) await session.flush() diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 39d85f0c6..875b7e95a 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent +from app.utils.content_utils import bootstrap_history_from_db from app.agents.new_chat.checkpointer import get_checkpointer from app.agents.new_chat.llm_config import ( AgentConfig, @@ -205,13 +206,13 @@ async def stream_new_chat( mentioned_document_ids: list[int] | None = None, mentioned_surfsense_doc_ids: list[int] | None = None, checkpoint_id: str | None = None, + needs_history_bootstrap: bool = False, ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. This uses the Vercel AI SDK Data Stream Protocol (SSE format) for streaming. The chat_id is used as LangGraph's thread_id for memory/checkpointing. - Message history can be passed from the frontend for context. Args: user_query: The user's query @@ -221,6 +222,7 @@ async def stream_new_chat( user_id: The current user's UUID string (for memory tools and session state) llm_config_id: The LLM configuration ID (default: -1 for first global config) attachments: Optional attachments with extracted content + needs_history_bootstrap: If True, load message history from DB (for cloned chats) mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat checkpoint_id: Optional checkpoint ID to rewind/fork from (for edit/reload operations) @@ -305,9 +307,24 @@ async def stream_new_chat( firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured ) - # Build input with message history from frontend + # Build input with message history langchain_messages = [] + # Bootstrap history for cloned chats (no LangGraph checkpoint exists yet) + if needs_history_bootstrap: + langchain_messages = await bootstrap_history_from_db(session, chat_id) + + # Clear the flag so we don't bootstrap again on next message + from app.db import NewChatThread + + thread_result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + thread = thread_result.scalars().first() + if thread: + thread.needs_history_bootstrap = False + await session.commit() + # Fetch mentioned documents if any (with chunks for proper citations) mentioned_documents: list[Document] = [] if mentioned_document_ids: diff --git a/surfsense_backend/app/utils/content_utils.py b/surfsense_backend/app/utils/content_utils.py new file mode 100644 index 000000000..d2342b79e --- /dev/null +++ b/surfsense_backend/app/utils/content_utils.py @@ -0,0 +1,75 @@ +""" +Utilities for working with message content. + +Message content in new_chat_messages can be stored in various formats: +- String: Simple text content +- List: Array of content parts [{"type": "text", "text": "..."}, {"type": "tool-call", ...}] +- Dict: Single content object + +These utilities help extract and transform content for different use cases. +""" + +from langchain_core.messages import AIMessage, HumanMessage +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +def extract_text_content(content: str | dict | list) -> str: + """Extract plain text content from various message formats.""" + if isinstance(content, str): + return content + if isinstance(content, dict): + # Handle dict with 'text' key + if "text" in content: + return content["text"] + return str(content) + if isinstance(content, list): + # Handle list of parts (e.g., [{"type": "text", "text": "..."}]) + texts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + texts.append(part.get("text", "")) + elif isinstance(part, str): + texts.append(part) + return "\n".join(texts) if texts else "" + return "" + + +async def bootstrap_history_from_db( + session: AsyncSession, + thread_id: int, +) -> list[HumanMessage | AIMessage]: + """ + Load message history from database and convert to LangChain format. + + Used for cloned chats where the LangGraph checkpointer has no state, + but we have messages in the database that should be used as context. + + Args: + session: Database session + thread_id: The chat thread ID + + Returns: + List of LangChain messages (HumanMessage/AIMessage) + """ + from app.db import NewChatMessage + + result = await session.execute( + select(NewChatMessage) + .filter(NewChatMessage.thread_id == thread_id) + .order_by(NewChatMessage.created_at) + ) + db_messages = result.scalars().all() + + langchain_messages: list[HumanMessage | AIMessage] = [] + + for msg in db_messages: + text_content = extract_text_content(msg.content) + if not text_content: + continue + if msg.role == "user": + langchain_messages.append(HumanMessage(content=text_content)) + elif msg.role == "assistant": + langchain_messages.append(AIMessage(content=text_content)) + + return langchain_messages From c65cda24d78b35e7efd03db600f260086539b556 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 27 Jan 2026 13:49:46 +0200 Subject: [PATCH 35/42] style: fix formatting issues --- surfsense_backend/app/routes/rbac_routes.py | 4 ++- .../app/tasks/chat/stream_new_chat.py | 2 +- .../new-chat/[[...chat_id]]/page.tsx | 3 +- .../dashboard/[search_space_id]/team/page.tsx | 34 ++++++++----------- .../components/auth/sign-in-button.tsx | 2 +- .../components/new-chat/chat-share-button.tsx | 14 ++++++-- surfsense_web/contracts/types/inbox.types.ts | 8 ++++- 7 files changed, 39 insertions(+), 28 deletions(-) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 5070a2724..7d2cc5c77 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -123,7 +123,9 @@ async def list_all_permissions( for perm in Permission: # Extract category from permission value (e.g., "documents:read" -> "documents") category = perm.value.split(":")[0] if ":" in perm.value else "general" - description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}") + description = PERMISSION_DESCRIPTIONS.get( + perm.value, f"Permission for {perm.value}" + ) permissions.append( PermissionInfo( diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 875b7e95a..12d7cbd4e 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -18,7 +18,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent -from app.utils.content_utils import bootstrap_history_from_db from app.agents.new_chat.checkpointer import get_checkpointer from app.agents.new_chat.llm_config import ( AgentConfig, @@ -35,6 +34,7 @@ from app.services.chat_session_state_service import ( ) from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService +from app.utils.content_utils import bootstrap_history_from_db def format_attachments_as_context(attachments: list[ChatAttachment]) -> str: diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index b5e63ca80..d025bceab 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -41,12 +41,12 @@ import { useMessagesElectric } from "@/hooks/use-messages-electric"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; import { getBearerToken } from "@/lib/auth-utils"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; +import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { isPodcastGenerating, looksLikePodcastRequest, setActivePodcastTaskId, } from "@/lib/chat/podcast-state"; -import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { appendMessage, type ChatVisibility, @@ -111,7 +111,6 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { return []; } - /** * Tools that should render custom UI in the chat. */ diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 298871cf7..87e4281ae 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -115,13 +115,13 @@ import type { Membership, UpdateMembershipRequest, } from "@/contracts/types/members.types"; +import type { PermissionInfo } from "@/contracts/types/permissions.types"; import type { CreateRoleRequest, DeleteRoleRequest, Role, UpdateRoleRequest, } from "@/contracts/types/roles.types"; -import type { PermissionInfo } from "@/contracts/types/permissions.types"; import { invitesApiService } from "@/lib/apis/invites-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events"; @@ -980,11 +980,7 @@ function RolesTab({ > {/* Create Role Button / Section */} {canCreate && !showCreateRole && ( - +