From 439b1547af975272a3330f12f710d274453ec5e7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 29 Jan 2026 16:05:36 +0200 Subject: [PATCH 01/16] refactor: add PublicChatSnapshot model, remove deprecated public share columns --- surfsense_backend/app/db.py | 108 ++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 22 deletions(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 876bc1d3c..825ab93eb 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -411,21 +411,6 @@ 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, - ) - # Whether public sharing is currently enabled for this thread - public_share_enabled = Column( - Boolean, - nullable=False, - default=False, - server_default="false", - ) - # Clone tracking - for audit and history bootstrap cloned_from_thread_id = Column( Integer, @@ -444,13 +429,6 @@ class NewChatThread(BaseModel, TimestampMixin): default=False, server_default="false", ) - # Flag indicating content clone is pending (two-phase clone) - clone_pending = Column( - Boolean, - nullable=False, - default=False, - server_default="false", - ) # Relationships search_space = relationship("SearchSpace", back_populates="new_chat_threads") @@ -461,6 +439,11 @@ class NewChatThread(BaseModel, TimestampMixin): order_by="NewChatMessage.created_at", cascade="all, delete-orphan", ) + snapshots = relationship( + "PublicChatSnapshot", + back_populates="thread", + cascade="all, delete-orphan", + ) class NewChatMessage(BaseModel, TimestampMixin): @@ -501,6 +484,87 @@ class NewChatMessage(BaseModel, TimestampMixin): ) +class PublicChatSnapshot(BaseModel, TimestampMixin): + """ + Immutable snapshot of a chat thread for public sharing. + + Each snapshot is a frozen copy of the chat at a specific point in time. + The snapshot_data JSONB contains all messages and metadata needed to + render the public chat without querying the original thread. + + Key features: + - Immutable: Content never changes after creation + - Deduplication: content_hash prevents duplicate snapshots of same state + - Cascade delete: Deleted when parent thread is deleted + - Message tracking: message_ids array enables cascade delete on message edit + """ + + __tablename__ = "public_chat_snapshots" + + # Link to original thread - CASCADE DELETE when thread is deleted + thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Public access token (unique URL identifier) + share_token = Column( + String(64), + nullable=False, + unique=True, + index=True, + ) + + # SHA-256 hash of message content for deduplication + # Same content = same hash = return existing snapshot instead of creating new + content_hash = Column( + String(64), + nullable=False, + index=True, + ) + + # Immutable snapshot data - self-contained for rendering + # Structure: + # { + # "version": 1, + # "title": "Chat title", + # "snapshot_at": "2026-01-29T12:00:00Z", + # "author": { "display_name": "...", "avatar_url": "..." }, + # "messages": [ + # { "id": 123, "role": "user|assistant", "content": [...], "author": {...}, "created_at": "..." } + # ], + # "podcasts": [ + # { "original_id": 456, "title": "...", "transcript": "...", "file_path": "..." } + # ] + # } + snapshot_data = Column(JSONB, nullable=False) + + # Array of message IDs included in this snapshot + # Used for cascade deletion when messages are edited/deleted + # GIN index enables fast array overlap queries + message_ids = Column(ARRAY(Integer), nullable=False) + + # Who created this snapshot + created_by_user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + # Relationships + thread = relationship("NewChatThread", back_populates="snapshots") + created_by = relationship("User") + + # Constraints + __table_args__ = ( + # Prevent duplicate snapshots of the same content for the same thread + UniqueConstraint("thread_id", "content_hash", name="uq_snapshot_thread_content_hash"), + ) + + class ChatComment(BaseModel, TimestampMixin): """ Comment model for comments on AI chat responses. From 665354b33d10996934614e403e64fa77857b8b93 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 29 Jan 2026 16:11:30 +0200 Subject: [PATCH 02/16] migration: add public_chat_snapshots table, drop deprecated columns --- .../84_add_public_chat_snapshots_table.py | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py diff --git a/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py b/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py new file mode 100644 index 000000000..39d7bf2dd --- /dev/null +++ b/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py @@ -0,0 +1,157 @@ +"""Add public_chat_snapshots table and remove deprecated columns from new_chat_threads + +Revision ID: 84 +Revises: 83 +Create Date: 2026-01-29 + +Changes: +1. Create public_chat_snapshots table for immutable public chat sharing +2. Drop deprecated columns from new_chat_threads: + - public_share_token (moved to snapshots) + - public_share_enabled (replaced by snapshot existence) + - clone_pending (single-phase clone) +3. Drop related indexes +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "84" +down_revision: str | None = "83" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create public_chat_snapshots table and remove deprecated columns.""" + + # 1. Create public_chat_snapshots table + op.execute( + """ + CREATE TABLE IF NOT EXISTS public_chat_snapshots ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + -- Link to original thread (CASCADE DELETE) + thread_id INTEGER NOT NULL + REFERENCES new_chat_threads(id) ON DELETE CASCADE, + + -- Public access token (unique URL identifier) + share_token VARCHAR(64) NOT NULL UNIQUE, + + -- SHA-256 hash of message content for deduplication + content_hash VARCHAR(64) NOT NULL, + + -- Immutable snapshot data (JSONB) + snapshot_data JSONB NOT NULL, + + -- Array of message IDs for cascade delete on edit + message_ids INTEGER[] NOT NULL, + + -- Who created this snapshot + created_by_user_id UUID REFERENCES "user"(id) ON DELETE SET NULL, + + -- Prevent duplicate snapshots of same content for same thread + CONSTRAINT uq_snapshot_thread_content_hash UNIQUE (thread_id, content_hash) + ); + """ + ) + + # 2. Create indexes for public_chat_snapshots + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_thread_id + ON public_chat_snapshots(thread_id); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_share_token + ON public_chat_snapshots(share_token); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_content_hash + ON public_chat_snapshots(content_hash); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_created_by_user_id + ON public_chat_snapshots(created_by_user_id); + """ + ) + + # 3. Create GIN index for message_ids array (for fast overlap queries) + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_message_ids + ON public_chat_snapshots USING GIN(message_ids); + """ + ) + + # 4. Drop deprecated indexes 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") + + # 5. Drop deprecated columns from new_chat_threads + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS clone_pending") + 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") + + +def downgrade() -> None: + """Restore deprecated columns and drop public_chat_snapshots table.""" + + # 1. Restore deprecated columns on 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( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS clone_pending BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + # 2. Restore indexes + 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; + """ + ) + + # 3. Drop public_chat_snapshots table and its indexes + op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_message_ids") + op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_created_by_user_id") + op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_content_hash") + op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_share_token") + op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_thread_id") + op.execute("DROP TABLE IF EXISTS public_chat_snapshots") From a45412abad6bbfe52465382d114fd1576a5dbb37 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 29 Jan 2026 20:24:50 +0200 Subject: [PATCH 03/16] refactor: rewrite public_chat_service for immutable snapshots --- .../app/services/public_chat_service.py | 579 ++++++++++++------ 1 file changed, 390 insertions(+), 189 deletions(-) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index a5b8c9ffe..e58329cf4 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -1,17 +1,36 @@ """ -Service layer for public chat sharing and cloning. +Service layer for public chat sharing via immutable snapshots. + +Key concepts: +- Snapshots are frozen copies of a chat at a specific point in time +- Content hash enables deduplication (same content = same URL) +- Podcasts are embedded in snapshot_data for self-contained public views +- Single-phase clone reads directly from snapshot_data """ +import contextlib +import hashlib +import json import re import secrets +from datetime import UTC, datetime from uuid import UUID from fastapi import HTTPException -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.db import NewChatThread, User +from app.db import ( + ChatVisibility, + NewChatMessage, + NewChatThread, + Podcast, + PodcastStatus, + PublicChatSnapshot, + SearchSpaceMembership, + User, +) UI_TOOLS = { "display_image", @@ -100,20 +119,241 @@ async def get_author_display( return user_cache[author_id] -async def toggle_public_share( +# ============================================================================= +# Content Hashing +# ============================================================================= + + +def compute_content_hash(messages: list[dict]) -> str: + """ + Compute SHA-256 hash of message content for deduplication. + + The hash is based on message IDs and content, ensuring that: + - Same messages = same hash = same URL (deduplication) + - Any change = different hash = new URL + """ + # Sort by message ID to ensure consistent ordering + sorted_messages = sorted(messages, key=lambda m: m.get("id", 0)) + + # Create normalized representation + normalized = [] + for msg in sorted_messages: + normalized.append( + { + "id": msg.get("id"), + "role": msg.get("role"), + "content": msg.get("content"), + } + ) + + content_str = json.dumps(normalized, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(content_str.encode()).hexdigest() + + +# ============================================================================= +# Snapshot Creation +# ============================================================================= + + +async def create_snapshot( session: AsyncSession, thread_id: int, - enabled: bool, user: User, base_url: str, ) -> dict: """ - Enable or disable public sharing for a thread. + Create a public snapshot of a chat 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. + Returns existing snapshot if content unchanged (same hash). + Returns new snapshot with unique URL if content changed. """ + result = await session.execute( + select(NewChatThread) + .options(selectinload(NewChatThread.messages)) + .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 create public snapshots", + ) + + # Build snapshot data + user_cache: dict[UUID, dict] = {} + messages_data = [] + message_ids = [] + podcasts_data = [] + podcast_ids_seen: set[int] = set() + + 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) + + # Extract podcast references (keep original podcast_id unchanged) + if isinstance(sanitized_content, list): + for part in sanitized_content: + if ( + isinstance(part, dict) + and part.get("type") == "tool-call" + and part.get("toolName") == "generate_podcast" + ): + result_data = part.get("result", {}) + podcast_id = result_data.get("podcast_id") + if podcast_id and podcast_id not in podcast_ids_seen: + + podcast_info = await _get_podcast_for_snapshot( + session, podcast_id + ) + if podcast_info: + podcasts_data.append(podcast_info) + podcast_ids_seen.add(podcast_id) + + + messages_data.append( + { + "id": msg.id, + "role": msg.role.value if hasattr(msg.role, "value") else str(msg.role), + "content": sanitized_content, + "author": author, + "author_id": str(msg.author_id) if msg.author_id else None, + "created_at": msg.created_at.isoformat() if msg.created_at else None, + } + ) + message_ids.append(msg.id) + + if not messages_data: + raise HTTPException(status_code=400, detail="Cannot share an empty chat") + + # Compute content hash for deduplication + content_hash = compute_content_hash(messages_data) + + # Check if identical snapshot already exists + existing_result = await session.execute( + select(PublicChatSnapshot).filter( + PublicChatSnapshot.thread_id == thread_id, + PublicChatSnapshot.content_hash == content_hash, + ) + ) + existing = existing_result.scalars().first() + + if existing: + # Return existing snapshot URL + return { + "snapshot_id": existing.id, + "share_token": existing.share_token, + "public_url": f"{base_url}/public/{existing.share_token}", + "is_new": False, + } + + # Get thread author info + thread_author = await get_author_display(session, thread.created_by_id, user_cache) + + # Create snapshot data + snapshot_data = { + "title": thread.title, + "snapshot_at": datetime.now(UTC).isoformat(), + "author": thread_author, + "messages": messages_data, + "podcasts": podcasts_data, + } + + # Create new snapshot + share_token = secrets.token_urlsafe(48) + snapshot = PublicChatSnapshot( + thread_id=thread_id, + share_token=share_token, + content_hash=content_hash, + snapshot_data=snapshot_data, + message_ids=message_ids, + created_by_user_id=user.id, + ) + session.add(snapshot) + await session.commit() + await session.refresh(snapshot) + + return { + "snapshot_id": snapshot.id, + "share_token": snapshot.share_token, + "public_url": f"{base_url}/public/{snapshot.share_token}", + "is_new": True, + } + + +async def _get_podcast_for_snapshot( + session: AsyncSession, + podcast_id: int, +) -> dict | None: + """Get podcast info for embedding in snapshot_data.""" + result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) + podcast = result.scalars().first() + + if not podcast or podcast.status != PodcastStatus.READY: + return None + + return { + "original_id": podcast.id, + "title": podcast.title, + "transcript": podcast.podcast_transcript, + "file_path": podcast.file_location, + } + + +# ============================================================================= +# Snapshot Retrieval +# ============================================================================= + + +async def get_snapshot_by_token( + session: AsyncSession, + share_token: str, +) -> PublicChatSnapshot | None: + """Get a snapshot by its share token.""" + result = await session.execute( + select(PublicChatSnapshot).filter( + PublicChatSnapshot.share_token == share_token + ) + ) + return result.scalars().first() + + +async def get_public_chat( + session: AsyncSession, + share_token: str, +) -> dict: + """ + Get public chat data from a snapshot. + + Returns sanitized content suitable for public viewing. + """ + snapshot = await get_snapshot_by_token(session, share_token) + + if not snapshot: + raise HTTPException(status_code=404, detail="Not found") + + data = snapshot.snapshot_data + + return { + "thread": { + "title": data.get("title", "Untitled"), + "created_at": data.get("snapshot_at"), + }, + "messages": data.get("messages", []), + } + + +async def list_snapshots_for_thread( + session: AsyncSession, + thread_id: int, + user: User, + base_url: str, +) -> list[dict]: + """List all public snapshots for a thread.""" + # Verify ownership result = await session.execute( select(NewChatThread).filter(NewChatThread.id == thread_id) ) @@ -125,92 +365,99 @@ async def toggle_public_share( if thread.created_by_id != user.id: raise HTTPException( status_code=403, - detail="Only the creator of this chat can manage public sharing", + detail="Only the creator can view snapshots", ) - if enabled and not thread.public_share_token: - thread.public_share_token = secrets.token_urlsafe(48) + # Get snapshots + result = await session.execute( + select(PublicChatSnapshot) + .filter(PublicChatSnapshot.thread_id == thread_id) + .order_by(PublicChatSnapshot.created_at.desc()) + ) + snapshots = result.scalars().all() - 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 [ + { + "id": s.id, + "share_token": s.share_token, + "public_url": f"{base_url}/public/{s.share_token}", + "created_at": s.created_at.isoformat() if s.created_at else None, + "message_count": len(s.message_ids) if s.message_ids else 0, } - - return { - "enabled": False, - "public_url": None, - "share_token": None, - } + for s in snapshots + ] -async def get_public_chat( +# ============================================================================= +# Snapshot Deletion +# ============================================================================= + + +async def delete_snapshot( session: AsyncSession, - share_token: str, -) -> dict: - """ - Get a public chat by share token. - - Returns sanitized content suitable for public viewing. - """ + thread_id: int, + snapshot_id: int, + user: User, +) -> bool: + """Delete a specific snapshot. Only thread owner can delete.""" + # Get snapshot with thread result = await session.execute( - select(NewChatThread) - .options(selectinload(NewChatThread.messages)) + select(PublicChatSnapshot) + .options(selectinload(PublicChatSnapshot.thread)) .filter( - NewChatThread.public_share_token == share_token, - NewChatThread.public_share_enabled.is_(True), + PublicChatSnapshot.id == snapshot_id, + PublicChatSnapshot.thread_id == thread_id, ) ) - thread = result.scalars().first() + snapshot = result.scalars().first() - if not thread: - raise HTTPException(status_code=404, detail="Not found") + if not snapshot: + raise HTTPException(status_code=404, detail="Snapshot 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, - } + if snapshot.thread.created_by_id != user.id: + raise HTTPException( + status_code=403, + detail="Only the creator can delete snapshots", ) - return { - "thread": { - "title": thread.title, - "created_at": thread.created_at, - }, - "messages": messages, - } + await session.delete(snapshot) + await session.commit() + return True -async def get_thread_by_share_token( +async def delete_affected_snapshots( session: AsyncSession, - share_token: str, -) -> NewChatThread | None: - """Get a thread by its public share token if sharing is enabled.""" + thread_id: int, + message_ids: list[int], +) -> int: + """ + Delete snapshots that contain any of the given message IDs. + + Called when messages are edited/deleted/regenerated. + + Returns the number of deleted snapshots. + """ + if not message_ids: + return 0 + + # Use raw SQL for array overlap query + # The && operator checks if arrays have any elements in common result = await session.execute( - select(NewChatThread) - .options(selectinload(NewChatThread.messages)) - .filter( - NewChatThread.public_share_token == share_token, - NewChatThread.public_share_enabled.is_(True), - ) + delete(PublicChatSnapshot) + .where(PublicChatSnapshot.thread_id == thread_id) + .where(PublicChatSnapshot.message_ids.overlap(message_ids)) + .returning(PublicChatSnapshot.id) ) - return result.scalars().first() + + deleted_ids = result.scalars().all() + await session.commit() + + return len(deleted_ids) + + +# ============================================================================= +# Cloning from Snapshot +# ============================================================================= async def get_user_default_search_space( @@ -222,8 +469,6 @@ async def get_user_default_search_space( 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( @@ -240,140 +485,96 @@ async def get_user_default_search_space( return None -async def complete_clone_content( +async def clone_from_snapshot( session: AsyncSession, - target_thread: NewChatThread, - source_thread_id: int, - target_search_space_id: int, -) -> int: + share_token: str, + user: User, +) -> dict: """ Copy messages and podcasts from source thread to target thread. - Sets clone_pending=False and needs_history_bootstrap=True when done. - Returns the number of messages copied. + Creates thread and copies messages from snapshot_data. + Returns the new thread info. """ - from app.db import NewChatMessage + # Get snapshot + snapshot = await get_snapshot_by_token(session, share_token) - result = await session.execute( - select(NewChatThread) - .options(selectinload(NewChatThread.messages)) - .filter(NewChatThread.id == source_thread_id) + if not snapshot: + raise HTTPException( + status_code=404, detail="Chat not found or no longer public" + ) + + # Get user's default search space + target_search_space_id = await get_user_default_search_space(session, user.id) + + if target_search_space_id is None: + raise HTTPException(status_code=400, detail="No search space found for user") + + # Get snapshot data + data = snapshot.snapshot_data + messages_data = data.get("messages", []) + + # Create new thread + new_thread = NewChatThread( + title=data.get("title", "Cloned Chat"), + archived=False, + visibility=ChatVisibility.PRIVATE, + search_space_id=target_search_space_id, + created_by_id=user.id, + cloned_from_thread_id=snapshot.thread_id, + cloned_at=datetime.now(UTC), + needs_history_bootstrap=True, ) - source_thread = result.scalars().first() + session.add(new_thread) + await session.flush() # Get thread ID - if not source_thread: - raise ValueError("Source thread not found") - - podcast_id_map: dict[int, int] = {} - message_count = 0 - - for msg in sorted(source_thread.messages, key=lambda m: m.created_at): - new_content = sanitize_content_for_public(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_data = part.get("result", {}) - old_podcast_id = result_data.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, - target_thread.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_data["podcast_id"] = podcast_id_map[old_podcast_id] - elif old_podcast_id: - # Podcast couldn't be cloned (not ready), remove reference - result_data.pop("podcast_id", None) + # Copy messages from snapshot_data (preserve original authors) + for msg_data in messages_data: + # Parse original author_id if present + original_author_id = None + author_id_str = msg_data.get("author_id") + if author_id_str: + with contextlib.suppress(ValueError, TypeError): + original_author_id = UUID(author_id_str) new_message = NewChatMessage( - thread_id=target_thread.id, - role=msg.role, - content=new_content, - author_id=msg.author_id, - created_at=msg.created_at, + thread_id=new_thread.id, + role=msg_data.get("role", "user"), + content=msg_data.get("content", []), + author_id=original_author_id, ) session.add(new_message) - message_count += 1 - - target_thread.clone_pending = False - target_thread.needs_history_bootstrap = True await session.commit() + await session.refresh(new_thread) - return message_count + return { + "thread_id": new_thread.id, + "search_space_id": target_search_space_id, + } -async def _clone_podcast( +async def get_snapshot_podcast( session: AsyncSession, + share_token: str, podcast_id: int, - target_search_space_id: int, - target_thread_id: int, -) -> int | None: - """Clone a podcast record and its audio file. Only clones ready podcasts.""" - import shutil - import uuid - from pathlib import Path +) -> dict | None: + """ + Get podcast info from a snapshot by original podcast ID. - from app.db import Podcast, PodcastStatus + Used for streaming podcast audio from public view. + Looks up the podcast by its original_id in the snapshot's podcasts array. + """ + snapshot = await get_snapshot_by_token(session, share_token) - result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) - original = result.scalars().first() - if not original or original.status != PodcastStatus.READY: + if not snapshot: 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) + podcasts = snapshot.snapshot_data.get("podcasts", []) - new_podcast = Podcast( - title=original.title, - podcast_transcript=original.podcast_transcript, - file_location=new_file_path, - status=PodcastStatus.READY, - search_space_id=target_search_space_id, - thread_id=target_thread_id, - ) - session.add(new_podcast) - await session.flush() + # Find podcast by original_id + for podcast in podcasts: + if podcast.get("original_id") == podcast_id: + return podcast - return new_podcast.id - - -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 + return None From e7242be7638734c55c8ff1df3ce64f2a41de6a52 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 29 Jan 2026 20:35:01 +0200 Subject: [PATCH 04/16] refactor: update public_chat_routes and schemas for snapshots --- .../app/routes/public_chat_routes.py | 89 ++++++++++--------- surfsense_backend/app/schemas/new_chat.py | 43 ++++----- 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index 4676f2ad0..d79c4dea5 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -1,21 +1,25 @@ """ -Routes for public chat access (unauthenticated and mixed-auth endpoints). +Routes for public chat access via immutable snapshots. + +All public endpoints use share_token for access - no authentication required +for read operations. Clone requires authentication. """ -from datetime import UTC, datetime +import os from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.db import ChatVisibility, NewChatThread, User, get_async_session +from app.db import User, get_async_session from app.schemas.new_chat import ( - CloneInitResponse, + CloneResponse, PublicChatResponse, ) from app.services.public_chat_service import ( + clone_from_snapshot, get_public_chat, - get_thread_by_share_token, - get_user_default_search_space, + get_snapshot_podcast, ) from app.users import current_active_user @@ -28,57 +32,60 @@ async def read_public_chat( session: AsyncSession = Depends(get_async_session), ): """ - Get a public chat by share token. + Get a public chat snapshot by share token. No authentication required. - Returns sanitized content (citations stripped). + Returns immutable snapshot data (sanitized, citations stripped). """ return await get_public_chat(session, share_token) -@router.post("/{share_token}/clone", response_model=CloneInitResponse) -async def clone_public_chat_endpoint( +@router.post("/{share_token}/clone", response_model=CloneResponse) +async def clone_public_chat( share_token: str, session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): """ - Initialize cloning a public chat to the user's account. - - Creates an empty thread with clone_pending=True. - Frontend should redirect to the new thread and call /complete-clone. + Clone a public chat snapshot to the user's account. + Single-phase clone: creates thread and copies messages in one request. Requires authentication. """ - source_thread = await get_thread_by_share_token(session, share_token) + return await clone_from_snapshot(session, share_token, user) - if not source_thread: - raise HTTPException( - status_code=404, detail="Chat not found or no longer public" - ) - target_search_space_id = await get_user_default_search_space(session, user.id) +@router.get("/{share_token}/podcasts/{podcast_id}/stream") +async def stream_public_podcast( + share_token: str, + podcast_id: int, + session: AsyncSession = Depends(get_async_session), +): + """ + Stream a podcast from a public chat snapshot. - if target_search_space_id is None: - raise HTTPException(status_code=400, detail="No search space found for user") + No authentication required - the share_token provides access. + Looks up podcast by original_id in the snapshot's podcasts array. + """ + podcast_info = await get_snapshot_podcast(session, share_token, podcast_id) - 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, - cloned_from_thread_id=source_thread.id, - cloned_at=datetime.now(UTC), - clone_pending=True, - ) - session.add(new_thread) - await session.commit() - await session.refresh(new_thread) - - return CloneInitResponse( - thread_id=new_thread.id, - search_space_id=target_search_space_id, - share_token=share_token, + if not podcast_info: + raise HTTPException(status_code=404, detail="Podcast not found") + + file_path = podcast_info.get("file_path") + + if not file_path or not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Podcast audio file not found") + + def iterfile(): + with open(file_path, mode="rb") as file_like: + yield from file_like + + return StreamingResponse( + iterfile(), + media_type="audio/mpeg", + headers={ + "Accept-Ranges": "bytes", + "Content-Disposition": f"inline; filename={os.path.basename(file_path)}", + }, ) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index ab6be9c9f..1c15c5f4d 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -95,9 +95,6 @@ class NewChatThreadRead(NewChatThreadBase, IDModel): search_space_id: int visibility: ChatVisibility created_by_id: UUID | None = None - public_share_enabled: bool = False - public_share_token: str | None = None - clone_pending: bool = False created_at: datetime updated_at: datetime @@ -137,7 +134,6 @@ class ThreadListItem(BaseModel): visibility: ChatVisibility created_by_id: UUID | None = None is_own_thread: bool = False - public_share_enabled: bool = False created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") @@ -211,22 +207,33 @@ class RegenerateRequest(BaseModel): # ============================================================================= -# Public Sharing Schemas +# Public Chat Snapshot Schemas # ============================================================================= -class PublicShareToggleRequest(BaseModel): - """Request to enable/disable public sharing for a thread.""" +class SnapshotCreateResponse(BaseModel): + """Response after creating a public snapshot.""" - enabled: bool + snapshot_id: int + share_token: str + public_url: str + is_new: bool # False if existing snapshot returned (same content) -class PublicShareToggleResponse(BaseModel): - """Response after toggling public sharing.""" +class SnapshotInfo(BaseModel): + """Info about a single snapshot.""" - enabled: bool - public_url: str | None = None - share_token: str | None = None + id: int + share_token: str + public_url: str + created_at: datetime + message_count: int + + +class SnapshotListResponse(BaseModel): + """List of snapshots for a thread.""" + + snapshots: list[SnapshotInfo] # ============================================================================= @@ -256,12 +263,8 @@ class PublicChatResponse(BaseModel): messages: list[PublicChatMessage] -class CloneInitResponse(BaseModel): +class CloneResponse(BaseModel): + """Response after cloning a public snapshot.""" + thread_id: int search_space_id: int - share_token: str - - -class CompleteCloneResponse(BaseModel): - status: str - message_count: int From 005ceaa2e8686ba6195a9a6a459e285eaed64a36 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 29 Jan 2026 20:43:48 +0200 Subject: [PATCH 05/16] refactor: add snapshot endpoints, remove deprecated clone/share endpoints --- .../app/routes/new_chat_routes.py | 149 +++++++++--------- 1 file changed, 75 insertions(+), 74 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 38352d348..a75b5594c 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -37,7 +37,6 @@ from app.db import ( get_async_session, ) from app.schemas.new_chat import ( - CompleteCloneResponse, NewChatMessageAppend, NewChatMessageRead, NewChatRequest, @@ -46,14 +45,13 @@ from app.schemas.new_chat import ( NewChatThreadUpdate, NewChatThreadVisibilityUpdate, NewChatThreadWithMessages, - PublicShareToggleRequest, - PublicShareToggleResponse, RegenerateRequest, + SnapshotCreateResponse, + SnapshotListResponse, 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 @@ -670,66 +668,6 @@ async def delete_thread( ) from None -@router.post( - "/threads/{thread_id}/complete-clone", response_model=CompleteCloneResponse -) -async def complete_clone( - thread_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Complete the cloning process for a thread. - - Copies messages and podcasts from the source thread. - Sets clone_pending=False and needs_history_bootstrap=True when done. - - Requires authentication and ownership of the thread. - """ - from app.services.public_chat_service import complete_clone_content - - try: - 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="Not authorized") - - if not thread.clone_pending: - raise HTTPException(status_code=400, detail="Clone already completed") - - if not thread.cloned_from_thread_id: - raise HTTPException( - status_code=400, detail="No source thread to clone from" - ) - - message_count = await complete_clone_content( - session=session, - target_thread=thread, - source_thread_id=thread.cloned_from_thread_id, - target_search_space_id=thread.search_space_id, - ) - - return CompleteCloneResponse( - status="success", - message_count=message_count, - ) - - except HTTPException: - raise - except Exception as e: - await session.rollback() - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred while completing clone: {e!s}", - ) from None - - @router.patch("/threads/{thread_id}/visibility", response_model=NewChatThreadRead) async def update_thread_visibility( thread_id: int, @@ -795,32 +733,83 @@ async def update_thread_visibility( ) from None -@router.patch( - "/threads/{thread_id}/public-share", response_model=PublicShareToggleResponse -) -async def update_thread_public_share( +# ============================================================================= +# Snapshot Endpoints +# ============================================================================= + + +@router.post("/threads/{thread_id}/snapshots", response_model=SnapshotCreateResponse) +async def create_thread_snapshot( 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. + Create a public snapshot of the 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. + Returns existing snapshot URL if content unchanged (deduplication). + Only the thread owner can create snapshots. """ + from app.services.public_chat_service import create_snapshot + base_url = str(request.base_url).rstrip("/") - return await toggle_public_share( + return await create_snapshot( session=session, thread_id=thread_id, - enabled=toggle_request.enabled, user=user, base_url=base_url, ) +@router.get("/threads/{thread_id}/snapshots", response_model=SnapshotListResponse) +async def list_thread_snapshots( + thread_id: int, + request: Request, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + List all public snapshots for this thread. + + Only the thread owner can view snapshots. + """ + from app.services.public_chat_service import list_snapshots_for_thread + + base_url = str(request.base_url).rstrip("/") + return SnapshotListResponse( + snapshots=await list_snapshots_for_thread( + session=session, + thread_id=thread_id, + user=user, + base_url=base_url, + ) + ) + + +@router.delete("/threads/{thread_id}/snapshots/{snapshot_id}") +async def delete_thread_snapshot( + thread_id: int, + snapshot_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Delete a specific snapshot. + + Only the thread owner can delete snapshots. + """ + from app.services.public_chat_service import delete_snapshot + + await delete_snapshot( + session=session, + thread_id=thread_id, + snapshot_id=snapshot_id, + user=user, + ) + return {"message": "Snapshot deleted successfully"} + + # ============================================================================= # Message Endpoints # ============================================================================= @@ -1326,9 +1315,21 @@ async def regenerate_response( # This ensures we don't lose data on streaming failures if streaming_completed and messages_to_delete: try: + # Get message IDs before deletion for snapshot cleanup + deleted_message_ids = [msg.id for msg in messages_to_delete] + for msg in messages_to_delete: await session.delete(msg) await session.commit() + + # Delete any public snapshots that contain the modified messages + from app.services.public_chat_service import ( + delete_affected_snapshots, + ) + + await delete_affected_snapshots( + session, thread_id, deleted_message_ids + ) except Exception as cleanup_error: # Log but don't fail - the new messages are already streamed print( From fb73a2e69fa29c88f63db60d9310896ba6f36b6d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 29 Jan 2026 20:49:05 +0200 Subject: [PATCH 06/16] refactor: require auth for podcast endpoints, remove public check --- .../app/routes/podcasts_routes.py | 59 +++++++------------ 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/surfsense_backend/app/routes/podcasts_routes.py b/surfsense_backend/app/routes/podcasts_routes.py index fa8326096..f991f698f 100644 --- a/surfsense_backend/app/routes/podcasts_routes.py +++ b/surfsense_backend/app/routes/podcasts_routes.py @@ -23,7 +23,7 @@ from app.db import ( get_async_session, ) from app.schemas import PodcastRead -from app.users import current_active_user, current_optional_user +from app.users import current_active_user from app.utils.rbac import check_permission router = APIRouter() @@ -82,17 +82,14 @@ async def read_podcasts( async def read_podcast( podcast_id: int, session: AsyncSession = Depends(get_async_session), - user: User | None = Depends(current_optional_user), + user: User = Depends(current_active_user), ): """ Get a specific podcast by ID. - Access is allowed if: - - User is authenticated with PODCASTS_READ permission, OR - - Podcast belongs to a publicly shared thread + Requires authentication with PODCASTS_READ permission. + For public podcast access, use /public/{share_token}/podcasts/{podcast_id}/stream """ - 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() @@ -103,18 +100,13 @@ async def read_podcast( 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 read podcasts in this 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", + ) return PodcastRead.from_orm_with_entries(podcast) except HTTPException as he: @@ -168,19 +160,16 @@ async def delete_podcast( async def stream_podcast( podcast_id: int, session: AsyncSession = Depends(get_async_session), - user: User | None = Depends(current_optional_user), + user: User = Depends(current_active_user), ): """ Stream a podcast audio file. - Access is allowed if: - - User is authenticated with PODCASTS_READ permission, OR - - Podcast belongs to a publicly shared thread + Requires authentication with PODCASTS_READ permission. + For public podcast access, use /public/{share_token}/podcasts/{podcast_id}/stream 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() @@ -188,19 +177,13 @@ async def stream_podcast( if not podcast: 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", - ) + 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", + ) file_path = podcast.file_location From 6aff69f4ec0863649bad59338821273196c3b047 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 14:19:56 +0200 Subject: [PATCH 07/16] feat: add snapshot types and API services --- .../contracts/types/chat-threads.types.ts | 52 ++++++++++++--- .../contracts/types/public-chat.types.ts | 17 +---- .../lib/apis/chat-threads-api.service.ts | 63 ++++++++++++++----- .../lib/apis/public-chat-api.service.ts | 26 -------- 4 files changed, 92 insertions(+), 66 deletions(-) diff --git a/surfsense_web/contracts/types/chat-threads.types.ts b/surfsense_web/contracts/types/chat-threads.types.ts index e5ca183bd..f761df0fd 100644 --- a/surfsense_web/contracts/types/chat-threads.types.ts +++ b/surfsense_web/contracts/types/chat-threads.types.ts @@ -1,19 +1,51 @@ import { z } from "zod"; /** - * Toggle public share + * Snapshot info */ -export const togglePublicShareRequest = z.object({ - thread_id: z.number(), - enabled: z.boolean(), +export const snapshotInfo = z.object({ + id: z.number(), + share_token: z.string(), + created_at: z.string(), }); -export const togglePublicShareResponse = z.object({ - enabled: z.boolean(), - public_url: z.string().nullable(), - share_token: z.string().nullable(), +/** + * Create snapshot + */ +export const createSnapshotRequest = z.object({ + thread_id: z.number(), +}); + +export const createSnapshotResponse = z.object({ + id: z.number(), + share_token: z.string(), + public_url: z.string(), + is_new: z.boolean(), +}); + +/** + * List snapshots + */ +export const listSnapshotsRequest = z.object({ + thread_id: z.number(), +}); + +export const listSnapshotsResponse = z.object({ + snapshots: z.array(snapshotInfo), +}); + +/** + * Delete snapshot + */ +export const deleteSnapshotRequest = z.object({ + thread_id: z.number(), + snapshot_id: z.number(), }); // Type exports -export type TogglePublicShareRequest = z.infer; -export type TogglePublicShareResponse = z.infer; +export type SnapshotInfo = z.infer; +export type CreateSnapshotRequest = z.infer; +export type CreateSnapshotResponse = z.infer; +export type ListSnapshotsRequest = z.infer; +export type ListSnapshotsResponse = z.infer; +export type DeleteSnapshotRequest = z.infer; diff --git a/surfsense_web/contracts/types/public-chat.types.ts b/surfsense_web/contracts/types/public-chat.types.ts index f7aea5969..11c338dfd 100644 --- a/surfsense_web/contracts/types/public-chat.types.ts +++ b/surfsense_web/contracts/types/public-chat.types.ts @@ -39,7 +39,7 @@ export const getPublicChatResponse = z.object({ }); /** - * Clone public chat (init) + * Clone public chat */ export const clonePublicChatRequest = z.object({ share_token: z.string(), @@ -48,19 +48,6 @@ export const clonePublicChatRequest = z.object({ export const clonePublicChatResponse = z.object({ thread_id: z.number(), search_space_id: z.number(), - share_token: z.string(), -}); - -/** - * Complete clone - */ -export const completeCloneRequest = z.object({ - thread_id: z.number(), -}); - -export const completeCloneResponse = z.object({ - status: z.string(), - message_count: z.number(), }); // Type exports @@ -71,5 +58,3 @@ export type GetPublicChatRequest = z.infer; export type GetPublicChatResponse = z.infer; export type ClonePublicChatRequest = z.infer; export type ClonePublicChatResponse = z.infer; -export type CompleteCloneRequest = z.infer; -export type CompleteCloneResponse = z.infer; diff --git a/surfsense_web/lib/apis/chat-threads-api.service.ts b/surfsense_web/lib/apis/chat-threads-api.service.ts index 9ad241c42..144defcb2 100644 --- a/surfsense_web/lib/apis/chat-threads-api.service.ts +++ b/surfsense_web/lib/apis/chat-threads-api.service.ts @@ -1,31 +1,66 @@ import { - type TogglePublicShareRequest, - type TogglePublicShareResponse, - togglePublicShareRequest, - togglePublicShareResponse, + type CreateSnapshotRequest, + type CreateSnapshotResponse, + createSnapshotRequest, + createSnapshotResponse, + type DeleteSnapshotRequest, + deleteSnapshotRequest, + type ListSnapshotsRequest, + type ListSnapshotsResponse, + listSnapshotsRequest, + listSnapshotsResponse, } 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. + * Create a public snapshot for a thread. */ - togglePublicShare = async ( - request: TogglePublicShareRequest - ): Promise => { - const parsed = togglePublicShareRequest.safeParse(request); + createSnapshot = async (request: CreateSnapshotRequest): Promise => { + const parsed = createSnapshotRequest.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 } } + return baseApiService.post( + `/api/v1/threads/${parsed.data.thread_id}/snapshots`, + createSnapshotResponse + ); + }; + + /** + * List all snapshots for a thread. + */ + listSnapshots = async (request: ListSnapshotsRequest): Promise => { + const parsed = listSnapshotsRequest.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/threads/${parsed.data.thread_id}/snapshots`, + listSnapshotsResponse + ); + }; + + /** + * Delete a specific snapshot. + */ + deleteSnapshot = async (request: DeleteSnapshotRequest): Promise => { + const parsed = deleteSnapshotRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + await baseApiService.delete( + `/api/v1/threads/${parsed.data.thread_id}/snapshots/${parsed.data.snapshot_id}` ); }; } diff --git a/surfsense_web/lib/apis/public-chat-api.service.ts b/surfsense_web/lib/apis/public-chat-api.service.ts index 49b1bd686..54fde2f46 100644 --- a/surfsense_web/lib/apis/public-chat-api.service.ts +++ b/surfsense_web/lib/apis/public-chat-api.service.ts @@ -1,12 +1,8 @@ import { type ClonePublicChatRequest, type ClonePublicChatResponse, - type CompleteCloneRequest, - type CompleteCloneResponse, clonePublicChatRequest, clonePublicChatResponse, - completeCloneRequest, - completeCloneResponse, type GetPublicChatRequest, type GetPublicChatResponse, getPublicChatRequest, @@ -18,7 +14,6 @@ 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); @@ -33,8 +28,6 @@ class PublicChatApiService { /** * Clone a public chat to the user's account. - * Creates an empty thread and returns thread_id for redirect. - * Requires authentication. */ clonePublicChat = async (request: ClonePublicChatRequest): Promise => { const parsed = clonePublicChatRequest.safeParse(request); @@ -49,25 +42,6 @@ class PublicChatApiService { clonePublicChatResponse ); }; - - /** - * Complete the clone by copying messages and podcasts. - * Called from the chat page after redirect. - * Requires authentication. - */ - completeClone = async (request: CompleteCloneRequest): Promise => { - const parsed = completeCloneRequest.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/threads/${parsed.data.thread_id}/complete-clone`, - completeCloneResponse - ); - }; } export const publicChatApiService = new PublicChatApiService(); From 98991d2ed424c6c7a480da6db216ec354e9ac8ed Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 14:20:06 +0200 Subject: [PATCH 08/16] feat: update UI for snapshot-based public sharing --- .../new-chat/[[...chat_id]]/page.tsx | 51 --------- .../atoms/chat/chat-thread-mutation.atoms.ts | 31 +++--- .../atoms/chat/current-thread.atom.ts | 4 - .../components/new-chat/chat-share-button.tsx | 103 ++++-------------- .../public-chat/public-chat-footer.tsx | 2 +- surfsense_web/lib/chat/thread-persistence.ts | 3 - 6 files changed, 39 insertions(+), 155 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 803bd6661..5d00232f6 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 @@ -42,7 +42,6 @@ import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user- import { Spinner } from "@/components/ui/spinner"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesElectric } from "@/hooks/use-messages-electric"; -import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; import { getBearerToken } from "@/lib/auth-utils"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; @@ -142,8 +141,6 @@ export default function NewChatPage() { const params = useParams(); const queryClient = useQueryClient(); const [isInitializing, setIsInitializing] = useState(true); - const [isCompletingClone, setIsCompletingClone] = useState(false); - const [cloneError, setCloneError] = useState(false); const [threadId, setThreadId] = useState(null); const [currentThread, setCurrentThread] = useState(null); const [messages, setMessages] = useState([]); @@ -332,42 +329,6 @@ export default function NewChatPage() { initializeThread(); }, [initializeThread]); - // Handle clone completion when thread has clone_pending flag - useEffect(() => { - if (!currentThread?.clone_pending || isCompletingClone || cloneError) return; - - const completeClone = async () => { - setIsCompletingClone(true); - - try { - await publicChatApiService.completeClone({ thread_id: currentThread.id }); - - // Re-initialize thread to fetch cloned content using existing logic - await initializeThread(); - - // Invalidate threads query to update sidebar - queryClient.invalidateQueries({ - predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads", - }); - } catch (error) { - console.error("[NewChatPage] Failed to complete clone:", error); - toast.error("Failed to copy chat content. Please try again."); - setCloneError(true); - } finally { - setIsCompletingClone(false); - } - }; - - completeClone(); - }, [ - currentThread?.clone_pending, - currentThread?.id, - isCompletingClone, - cloneError, - initializeThread, - queryClient, - ]); - // Handle scroll to comment from URL query params (e.g., from inbox item click) const searchParams = useSearchParams(); const targetCommentIdParam = searchParams.get("commentId"); @@ -394,8 +355,6 @@ export default function NewChatPage() { visibility: currentThread?.visibility ?? null, hasComments: currentThread?.has_comments ?? false, addingCommentToMessageId: null, - publicShareEnabled: currentThread?.public_share_enabled ?? false, - publicShareToken: currentThread?.public_share_token ?? null, })); }, [currentThread, setCurrentThreadState]); @@ -1420,16 +1379,6 @@ export default function NewChatPage() { ); } - // Show loading state while completing clone - if (isCompletingClone) { - return ( -
- -
Copying chat content...
-
- ); - } - // Show error state only if we tried to load an existing thread but failed // For new chats (urlChatId === 0), threadId being null is expected (lazy creation) if (!threadId && urlChatId > 0) { diff --git a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts index a844a45fb..d8c158fd1 100644 --- a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts +++ b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts @@ -1,28 +1,31 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { - TogglePublicShareRequest, - TogglePublicShareResponse, + CreateSnapshotRequest, + CreateSnapshotResponse, } 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); +export const createSnapshotMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: CreateSnapshotRequest) => { + return chatThreadsApiService.createSnapshot(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", + onSuccess: (response: CreateSnapshotResponse) => { + // Construct URL using frontend origin (backend returns its own URL which differs) + const publicUrl = `${window.location.origin}/public/${response.share_token}`; + navigator.clipboard.writeText(publicUrl); + if (response.is_new) { + toast.success("Public link created and copied to clipboard", { + description: "Anyone with this link can view a snapshot of this chat", }); } else { - toast.success("Public sharing disabled"); + toast.success("Public link copied to clipboard", { + description: "This snapshot already exists", + }); } }, onError: (error: Error) => { - console.error("Failed to toggle public share:", error); - toast.error("Failed to update public sharing"); + console.error("Failed to create snapshot:", error); + toast.error("Failed to create public link"); }, })); diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index 54f2190fe..5de11eb92 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -19,8 +19,6 @@ interface CurrentThreadState { addingCommentToMessageId: number | null; /** Whether the right-side comments panel is collapsed (desktop only) */ commentsCollapsed: boolean; - publicShareEnabled: boolean; - publicShareToken: string | null; } const initialState: CurrentThreadState = { @@ -29,8 +27,6 @@ const initialState: CurrentThreadState = { hasComments: false, addingCommentToMessageId: null, commentsCollapsed: false, - publicShareEnabled: false, - publicShareToken: null, }; export const currentThreadAtom = atom(initialState); diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 9a1b3c426..f523752f1 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -2,10 +2,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { Globe, Link2, User, Users } from "lucide-react"; +import { Globe, User, Users } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; -import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; +import { createSnapshotMutationAtom } 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"; @@ -49,19 +49,15 @@ 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 + // Snapshot creation mutation + const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue( + createSnapshotMutationAtom ); // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; - const isPublicEnabled = - currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false; - const publicShareToken = currentThreadState.publicShareToken ?? null; const handleVisibilityChange = useCallback( async (newVisibility: ChatVisibility) => { @@ -96,45 +92,24 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility] ); - const handlePublicShareToggle = useCallback(async () => { + const handleCreatePublicLink = 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, - })); + await createSnapshot({ thread_id: thread.id }); + setOpen(false); } catch (error) { - console.error("Failed to toggle public share:", error); + console.error("Failed to create public link:", 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]); + }, [thread, createSnapshot]); // Don't show if no thread (new chat that hasn't been created yet) if (!thread) { return null; } - const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users; - const buttonLabel = isPublicEnabled - ? "Public" - : currentVisibility === "PRIVATE" - ? "Private" - : "Shared"; + const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users; + const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared"; return ( @@ -211,67 +186,31 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS {/* Divider */}
- {/* Public Share Option */} + {/* Public Link Option */}
diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx index cf4501c23..2211f3142 100644 --- a/surfsense_web/components/public-chat/public-chat-footer.tsx +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -26,7 +26,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { share_token: shareToken, }); - // Redirect to the new chat page (content will be loaded there) + // Redirect to the new chat page with cloned content router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`); } catch (error) { const message = error instanceof Error ? error.message : "Failed to copy chat"; diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index 540fbdc70..08c08ba78 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -24,9 +24,6 @@ export interface ThreadRecord { created_at: string; updated_at: string; has_comments?: boolean; - public_share_enabled?: boolean; - public_share_token?: string | null; - clone_pending?: boolean; } export interface MessageRecord { From b8338d8643169172a834beb59b30392e762ed02b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 14:36:35 +0200 Subject: [PATCH 09/16] feat: add public podcast endpoints and player support --- .../app/routes/public_chat_routes.py | 27 +++++++- .../components/tool-ui/generate-podcast.tsx | 62 ++++++++++++++----- 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index d79c4dea5..b5f523429 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -49,12 +49,37 @@ async def clone_public_chat( """ Clone a public chat snapshot to the user's account. - Single-phase clone: creates thread and copies messages in one request. + Creates thread and copies messages. Requires authentication. """ return await clone_from_snapshot(session, share_token, user) +@router.get("/{share_token}/podcasts/{podcast_id}") +async def get_public_podcast( + share_token: str, + podcast_id: int, + session: AsyncSession = Depends(get_async_session), +): + """ + Get podcast details from a public chat snapshot. + + No authentication required - the share_token provides access. + Returns podcast info including transcript. + """ + podcast_info = await get_snapshot_podcast(session, share_token, podcast_id) + + if not podcast_info: + raise HTTPException(status_code=404, detail="Podcast not found") + + return { + "id": podcast_info.get("original_id"), + "title": podcast_info.get("title"), + "status": "ready", + "podcast_transcript": podcast_info.get("transcript"), + } + + @router.get("/{share_token}/podcasts/{podcast_id}/stream") async def stream_public_podcast( share_token: str, diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 3ae0755ef..64892ebab 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -173,7 +173,18 @@ function AudioLoadingState({ title }: { title: string }) { } /** - * Podcast Player Component - Fetches audio and transcript with authentication + * Get public share token from URL if in public view. + * Returns null if not in a public view. + */ +function getPublicShareToken(): string | null { + if (typeof window === "undefined") return null; + const match = window.location.pathname.match(/^\/public\/([^/]+)/); + return match ? match[1] : null; +} + +/** + * Podcast Player Component - Fetches audio and transcript + * Automatically uses public endpoint when viewing a public chat snapshot. */ function PodcastPlayer({ podcastId, @@ -217,30 +228,49 @@ function PodcastPlayer({ const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout try { - // Fetch audio blob and podcast details in parallel - const [audioResponse, rawPodcastDetails] = await Promise.all([ - authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, - { method: "GET", signal: controller.signal } - ), - baseApiService.get(`/api/v1/podcasts/${podcastId}`), - ]); + // Check if we're in a public view + const shareToken = getPublicShareToken(); - if (!audioResponse.ok) { - throw new Error(`Failed to load audio: ${audioResponse.status}`); + let audioBlob: Blob; + let rawPodcastDetails: unknown = null; + + if (shareToken) { + // Public view - use public endpoints (baseApiService handles no-auth for /api/v1/public/) + const [blob, details] = await Promise.all([ + baseApiService.getBlob(`/api/v1/public/${shareToken}/podcasts/${podcastId}/stream`), + baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`), + ]); + audioBlob = blob; + rawPodcastDetails = details; + } else { + // Authenticated view - fetch audio and details in parallel + const [audioResponse, details] = await Promise.all([ + authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, + { method: "GET", signal: controller.signal } + ), + baseApiService.get(`/api/v1/podcasts/${podcastId}`), + ]); + + if (!audioResponse.ok) { + throw new Error(`Failed to load audio: ${audioResponse.status}`); + } + + audioBlob = await audioResponse.blob(); + rawPodcastDetails = details; } - const audioBlob = await audioResponse.blob(); - // Create object URL from blob const objectUrl = URL.createObjectURL(audioBlob); objectUrlRef.current = objectUrl; setAudioSrc(objectUrl); // Parse and validate podcast details, then set transcript - const podcastDetails = parsePodcastDetails(rawPodcastDetails); - if (podcastDetails.podcast_transcript) { - setTranscript(podcastDetails.podcast_transcript); + if (rawPodcastDetails) { + const podcastDetails = parsePodcastDetails(rawPodcastDetails); + if (podcastDetails.podcast_transcript) { + setTranscript(podcastDetails.podcast_transcript); + } } } finally { clearTimeout(timeoutId); From b932a3d92621e406f63ca717a448d5d24383ab9f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 14:40:26 +0200 Subject: [PATCH 10/16] fix: align snapshot schema with backend response --- surfsense_web/contracts/types/chat-threads.types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_web/contracts/types/chat-threads.types.ts b/surfsense_web/contracts/types/chat-threads.types.ts index f761df0fd..d360c3732 100644 --- a/surfsense_web/contracts/types/chat-threads.types.ts +++ b/surfsense_web/contracts/types/chat-threads.types.ts @@ -6,7 +6,9 @@ import { z } from "zod"; export const snapshotInfo = z.object({ id: z.number(), share_token: z.string(), + public_url: z.string(), created_at: z.string(), + message_count: z.number(), }); /** @@ -17,7 +19,7 @@ export const createSnapshotRequest = z.object({ }); export const createSnapshotResponse = z.object({ - id: z.number(), + snapshot_id: z.number(), share_token: z.string(), public_url: z.string(), is_new: z.boolean(), From e3d6b1d789cbfa0c6eee927e6a9dce981f576119 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 14:45:55 +0200 Subject: [PATCH 11/16] fix: remove deprecated public_share_enabled from thread list --- surfsense_backend/app/routes/new_chat_routes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index a75b5594c..f2c26e449 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -217,7 +217,6 @@ 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,7 +318,6 @@ 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, ) From 070bb42506e9bed6d48be91a34b5f0ee3c2a8764 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 16:21:01 +0200 Subject: [PATCH 12/16] feat: clone podcasts when cloning public chat Creates new podcast records for cloned user with thread_id, updates podcast_id references in message content. --- .../app/services/public_chat_service.py | 57 +++++++++++++++---- .../components/tool-ui/generate-podcast.tsx | 25 +++----- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index e58329cf4..f46e85a23 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -194,7 +194,7 @@ async def create_snapshot( author = await get_author_display(session, msg.author_id, user_cache) sanitized_content = sanitize_content_for_public(msg.content) - # Extract podcast references (keep original podcast_id unchanged) + # Extract podcast references and update status to "ready" for completed podcasts if isinstance(sanitized_content, list): for part in sanitized_content: if ( @@ -205,13 +205,14 @@ async def create_snapshot( result_data = part.get("result", {}) podcast_id = result_data.get("podcast_id") if podcast_id and podcast_id not in podcast_ids_seen: - podcast_info = await _get_podcast_for_snapshot( session, podcast_id ) if podcast_info: podcasts_data.append(podcast_info) podcast_ids_seen.add(podcast_id) + # Update status to "ready" so frontend renders PodcastPlayer + part["result"] = {**result_data, "status": "ready"} messages_data.append( @@ -494,9 +495,12 @@ async def clone_from_snapshot( Copy messages and podcasts from source thread to target thread. Creates thread and copies messages from snapshot_data. + When encountering generate_podcast tool-calls, creates cloned podcast records + and updates the podcast_id references inline. Returns the new thread info. """ - # Get snapshot + import copy + snapshot = await get_snapshot_by_token(session, share_token) if not snapshot: @@ -504,17 +508,15 @@ async def clone_from_snapshot( status_code=404, detail="Chat not found or no longer public" ) - # Get user's default search space target_search_space_id = await get_user_default_search_space(session, user.id) if target_search_space_id is None: raise HTTPException(status_code=400, detail="No search space found for user") - # Get snapshot data data = snapshot.snapshot_data messages_data = data.get("messages", []) + podcasts_lookup = {p.get("original_id"): p for p in data.get("podcasts", [])} - # Create new thread new_thread = NewChatThread( title=data.get("title", "Cloned Chat"), archived=False, @@ -526,22 +528,55 @@ async def clone_from_snapshot( needs_history_bootstrap=True, ) session.add(new_thread) - await session.flush() # Get thread ID + await session.flush() + + podcast_id_mapping: dict[int, int] = {} - # Copy messages from snapshot_data (preserve original authors) for msg_data in messages_data: - # Parse original author_id if present original_author_id = None author_id_str = msg_data.get("author_id") if author_id_str: with contextlib.suppress(ValueError, TypeError): original_author_id = UUID(author_id_str) + content = copy.deepcopy(msg_data.get("content", [])) + + if isinstance(content, list): + for part in 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_mapping: + podcast_info = podcasts_lookup.get(old_podcast_id) + if podcast_info: + new_podcast = Podcast( + title=podcast_info.get("title", "Cloned Podcast"), + podcast_transcript=podcast_info.get("transcript"), + file_location=podcast_info.get("file_path"), + status=PodcastStatus.READY, + search_space_id=target_search_space_id, + thread_id=new_thread.id, + ) + session.add(new_podcast) + await session.flush() + podcast_id_mapping[old_podcast_id] = new_podcast.id + + if old_podcast_id and old_podcast_id in podcast_id_mapping: + part["result"] = { + **result, + "podcast_id": podcast_id_mapping[old_podcast_id], + } + new_message = NewChatMessage( thread_id=new_thread.id, role=msg_data.get("role", "user"), - content=msg_data.get("content", []), - author_id=original_author_id, + content=content, + author_id=original_author_id, ) session.add(new_message) diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 64892ebab..9d6d47588 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -2,6 +2,7 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { AlertCircleIcon, MicIcon } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { Audio } from "@/components/tool-ui/audio"; @@ -172,20 +173,6 @@ function AudioLoadingState({ title }: { title: string }) { ); } -/** - * Get public share token from URL if in public view. - * Returns null if not in a public view. - */ -function getPublicShareToken(): string | null { - if (typeof window === "undefined") return null; - const match = window.location.pathname.match(/^\/public\/([^/]+)/); - return match ? match[1] : null; -} - -/** - * Podcast Player Component - Fetches audio and transcript - * Automatically uses public endpoint when viewing a public chat snapshot. - */ function PodcastPlayer({ podcastId, title, @@ -197,6 +184,11 @@ function PodcastPlayer({ description: string; durationMs?: number; }) { + const params = useParams(); + const pathname = usePathname(); + const isPublicRoute = pathname?.startsWith("/public/"); + const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null; + const [audioSrc, setAudioSrc] = useState(null); const [transcript, setTranscript] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -228,9 +220,6 @@ function PodcastPlayer({ const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout try { - // Check if we're in a public view - const shareToken = getPublicShareToken(); - let audioBlob: Blob; let rawPodcastDetails: unknown = null; @@ -285,7 +274,7 @@ function PodcastPlayer({ } finally { setIsLoading(false); } - }, [podcastId]); + }, [podcastId, shareToken]); // Load podcast when component mounts useEffect(() => { From 2ec7050603c98ae79e175cacefbc236971056fa6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 16:53:10 +0200 Subject: [PATCH 13/16] fix: handle deleted authors when cloning public chat --- .../app/services/public_chat_service.py | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index f46e85a23..8dde5e73a 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -8,7 +8,6 @@ Key concepts: - Single-phase clone reads directly from snapshot_data """ -import contextlib import hashlib import json import re @@ -532,12 +531,34 @@ async def clone_from_snapshot( podcast_id_mapping: dict[int, int] = {} + # Check which authors from snapshot still exist in DB + author_ids_from_snapshot: set[UUID] = set() for msg_data in messages_data: - original_author_id = None - author_id_str = msg_data.get("author_id") - if author_id_str: - with contextlib.suppress(ValueError, TypeError): - original_author_id = UUID(author_id_str) + if author_str := msg_data.get("author_id"): + try: + author_ids_from_snapshot.add(UUID(author_str)) + except (ValueError, TypeError): + pass + + existing_authors: set[UUID] = set() + if author_ids_from_snapshot: + result = await session.execute( + select(User.id).where(User.id.in_(author_ids_from_snapshot)) + ) + existing_authors = {row[0] for row in result.fetchall()} + + for msg_data in messages_data: + role = msg_data.get("role", "user") + + # Use original author if exists, otherwise None + author_id = None + if author_str := msg_data.get("author_id"): + try: + parsed_id = UUID(author_str) + if parsed_id in existing_authors: + author_id = parsed_id + except (ValueError, TypeError): + pass content = copy.deepcopy(msg_data.get("content", [])) @@ -574,9 +595,9 @@ async def clone_from_snapshot( new_message = NewChatMessage( thread_id=new_thread.id, - role=msg_data.get("role", "user"), + role=role, content=content, - author_id=original_author_id, + author_id=author_id, ) session.add(new_message) From bc0fb3cb68c26bcf707137986022eb1cb640acbd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 17:08:07 +0200 Subject: [PATCH 14/16] feat: track cloned_from_snapshot_id for cloned chats --- .../84_add_public_chat_snapshots_table.py | 24 +++++++++++- surfsense_backend/app/db.py | 39 ++++++------------- .../app/services/public_chat_service.py | 1 + 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py b/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py index 39d7bf2dd..1da758c7d 100644 --- a/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py +++ b/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py @@ -11,6 +11,7 @@ Changes: - public_share_enabled (replaced by snapshot existence) - clone_pending (single-phase clone) 3. Drop related indexes +4. Add cloned_from_snapshot_id to new_chat_threads (tracks source snapshot for clones) """ from collections.abc import Sequence @@ -105,11 +106,32 @@ def upgrade() -> None: 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") + # 6. Add cloned_from_snapshot_id to new_chat_threads + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS cloned_from_snapshot_id INTEGER + REFERENCES public_chat_snapshots(id) ON DELETE SET NULL; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_cloned_from_snapshot_id + ON new_chat_threads(cloned_from_snapshot_id) + WHERE cloned_from_snapshot_id IS NOT NULL; + """ + ) + def downgrade() -> None: """Restore deprecated columns and drop public_chat_snapshots table.""" - # 1. Restore deprecated columns on new_chat_threads + # 1. Drop cloned_from_snapshot_id column and index + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_cloned_from_snapshot_id") + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS cloned_from_snapshot_id") + + # 2. Restore deprecated columns on new_chat_threads op.execute( """ ALTER TABLE new_chat_threads diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 825ab93eb..3abdf1c9f 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -418,6 +418,12 @@ class NewChatThread(BaseModel, TimestampMixin): nullable=True, index=True, ) + cloned_from_snapshot_id = Column( + Integer, + ForeignKey("public_chat_snapshots.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) cloned_at = Column( TIMESTAMP(timezone=True), nullable=True, @@ -443,6 +449,7 @@ class NewChatThread(BaseModel, TimestampMixin): "PublicChatSnapshot", back_populates="thread", cascade="all, delete-orphan", + foreign_keys="[PublicChatSnapshot.thread_id]", ) @@ -491,12 +498,6 @@ class PublicChatSnapshot(BaseModel, TimestampMixin): Each snapshot is a frozen copy of the chat at a specific point in time. The snapshot_data JSONB contains all messages and metadata needed to render the public chat without querying the original thread. - - Key features: - - Immutable: Content never changes after creation - - Deduplication: content_hash prevents duplicate snapshots of same state - - Cascade delete: Deleted when parent thread is deleted - - Message tracking: message_ids array enables cascade delete on message edit """ __tablename__ = "public_chat_snapshots" @@ -517,36 +518,16 @@ class PublicChatSnapshot(BaseModel, TimestampMixin): index=True, ) - # SHA-256 hash of message content for deduplication - # Same content = same hash = return existing snapshot instead of creating new content_hash = Column( String(64), nullable=False, index=True, ) - # Immutable snapshot data - self-contained for rendering - # Structure: - # { - # "version": 1, - # "title": "Chat title", - # "snapshot_at": "2026-01-29T12:00:00Z", - # "author": { "display_name": "...", "avatar_url": "..." }, - # "messages": [ - # { "id": 123, "role": "user|assistant", "content": [...], "author": {...}, "created_at": "..." } - # ], - # "podcasts": [ - # { "original_id": 456, "title": "...", "transcript": "...", "file_path": "..." } - # ] - # } snapshot_data = Column(JSONB, nullable=False) - # Array of message IDs included in this snapshot - # Used for cascade deletion when messages are edited/deleted - # GIN index enables fast array overlap queries message_ids = Column(ARRAY(Integer), nullable=False) - # Who created this snapshot created_by_user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="SET NULL"), @@ -555,7 +536,11 @@ class PublicChatSnapshot(BaseModel, TimestampMixin): ) # Relationships - thread = relationship("NewChatThread", back_populates="snapshots") + thread = relationship( + "NewChatThread", + back_populates="snapshots", + foreign_keys="[PublicChatSnapshot.thread_id]", + ) created_by = relationship("User") # Constraints diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 8dde5e73a..f0d8eb18e 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -523,6 +523,7 @@ async def clone_from_snapshot( search_space_id=target_search_space_id, created_by_id=user.id, cloned_from_thread_id=snapshot.thread_id, + cloned_from_snapshot_id=snapshot.id, cloned_at=datetime.now(UTC), needs_history_bootstrap=True, ) From d2ebd3ec00c66719fcd09fbeaa2767e3801d44ec Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 30 Jan 2026 18:44:33 +0200 Subject: [PATCH 15/16] fix: snapshot deletion during regeneration --- .../app/routes/new_chat_routes.py | 7 ++--- .../app/services/public_chat_service.py | 30 ++++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index f2c26e449..5e1bc238f 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -1273,6 +1273,8 @@ async def regenerate_response( .limit(2) ) messages_to_delete = list(last_messages_result.scalars().all()) + + message_ids_to_delete = [msg.id for msg in messages_to_delete] # Get search space for LLM config search_space_result = await session.execute( @@ -1313,9 +1315,6 @@ async def regenerate_response( # This ensures we don't lose data on streaming failures if streaming_completed and messages_to_delete: try: - # Get message IDs before deletion for snapshot cleanup - deleted_message_ids = [msg.id for msg in messages_to_delete] - for msg in messages_to_delete: await session.delete(msg) await session.commit() @@ -1326,7 +1325,7 @@ async def regenerate_response( ) await delete_affected_snapshots( - session, thread_id, deleted_message_ids + session, thread_id, message_ids_to_delete ) except Exception as cleanup_error: # Log but don't fail - the new messages are already streamed diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index f0d8eb18e..5e8580642 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -426,7 +426,7 @@ async def delete_snapshot( async def delete_affected_snapshots( - session: AsyncSession, + session: AsyncSession, # noqa: ARG001 - kept for API compatibility thread_id: int, message_ids: list[int], ) -> int: @@ -434,25 +434,27 @@ async def delete_affected_snapshots( Delete snapshots that contain any of the given message IDs. Called when messages are edited/deleted/regenerated. - - Returns the number of deleted snapshots. + Uses independent session to work reliably in streaming response cleanup. """ if not message_ids: return 0 - # Use raw SQL for array overlap query - # The && operator checks if arrays have any elements in common - result = await session.execute( - delete(PublicChatSnapshot) - .where(PublicChatSnapshot.thread_id == thread_id) - .where(PublicChatSnapshot.message_ids.overlap(message_ids)) - .returning(PublicChatSnapshot.id) - ) + from sqlalchemy.dialects.postgresql import array - deleted_ids = result.scalars().all() - await session.commit() + from app.db import async_session_maker - return len(deleted_ids) + async with async_session_maker() as independent_session: + result = await independent_session.execute( + delete(PublicChatSnapshot) + .where(PublicChatSnapshot.thread_id == thread_id) + .where(PublicChatSnapshot.message_ids.op("&&")(array(message_ids))) + .returning(PublicChatSnapshot.id) + ) + + deleted_ids = result.scalars().all() + await independent_session.commit() + + return len(deleted_ids) # ============================================================================= From c2e24723564a142a4e3faddd8670b33e00a534aa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sun, 1 Feb 2026 14:40:49 +0200 Subject: [PATCH 16/16] fix: rename migration to 85, remove obsolete noAuth prefix - Rename snapshot migration from 84 to 85 to avoid conflict with upstream's LLM auto-mode migration - Remove /api/v1/podcasts/ from noAuthPrefixes since all podcast endpoints now require authentication Co-authored-by: Cursor --- ...ots_table.py => 85_add_public_chat_snapshots_table.py} | 8 ++++---- surfsense_web/lib/apis/base-api.service.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename surfsense_backend/alembic/versions/{84_add_public_chat_snapshots_table.py => 85_add_public_chat_snapshots_table.py} (98%) diff --git a/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py b/surfsense_backend/alembic/versions/85_add_public_chat_snapshots_table.py similarity index 98% rename from surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py rename to surfsense_backend/alembic/versions/85_add_public_chat_snapshots_table.py index 1da758c7d..60c31f039 100644 --- a/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py +++ b/surfsense_backend/alembic/versions/85_add_public_chat_snapshots_table.py @@ -1,7 +1,7 @@ """Add public_chat_snapshots table and remove deprecated columns from new_chat_threads -Revision ID: 84 -Revises: 83 +Revision ID: 85 +Revises: 84 Create Date: 2026-01-29 Changes: @@ -19,8 +19,8 @@ from collections.abc import Sequence from alembic import op # revision identifiers, used by Alembic. -revision: str = "84" -down_revision: str | None = "83" +revision: str = "85" +down_revision: str | None = "84" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None 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