diff --git a/surfsense_backend/alembic/versions/81_add_public_chat_features.py b/surfsense_backend/alembic/versions/81_add_public_chat_features.py new file mode 100644 index 000000000..8d7e95df7 --- /dev/null +++ b/surfsense_backend/alembic/versions/81_add_public_chat_features.py @@ -0,0 +1,114 @@ +"""Add public chat sharing and cloning features to new_chat_threads + +Revision ID: 81 +Revises: 80 +Create Date: 2026-01-23 + +Adds columns for: +1. Public sharing via tokenized URLs (public_share_token, public_share_enabled) +2. Clone tracking for audit (cloned_from_thread_id, cloned_at) +3. History bootstrap flag for cloned chats (needs_history_bootstrap) +4. Clone pending flag for two-phase clone (clone_pending) +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "81" +down_revision: str | None = "80" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add public sharing and cloning columns to new_chat_threads.""" + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS public_share_token VARCHAR(64); + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS public_share_enabled BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_token + ON new_chat_threads(public_share_token) + WHERE public_share_token IS NOT NULL; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_enabled + ON new_chat_threads(public_share_enabled) + WHERE public_share_enabled = TRUE; + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS cloned_from_thread_id INTEGER + REFERENCES new_chat_threads(id) ON DELETE SET NULL; + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS cloned_at TIMESTAMP WITH TIME ZONE; + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS needs_history_bootstrap BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS clone_pending BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_cloned_from_thread_id + ON new_chat_threads(cloned_from_thread_id) + WHERE cloned_from_thread_id IS NOT NULL; + """ + ) + + +def downgrade() -> None: + """Remove public sharing and cloning columns from new_chat_threads.""" + + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_cloned_from_thread_id") + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS clone_pending") + op.execute( + "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS needs_history_bootstrap" + ) + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS cloned_at") + op.execute( + "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS cloned_from_thread_id" + ) + + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_enabled") + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_token") + op.execute( + "ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_enabled" + ) + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_token") diff --git a/surfsense_backend/alembic/versions/82_add_podcast_status_and_thread.py b/surfsense_backend/alembic/versions/82_add_podcast_status_and_thread.py new file mode 100644 index 000000000..fd4eed89f --- /dev/null +++ b/surfsense_backend/alembic/versions/82_add_podcast_status_and_thread.py @@ -0,0 +1,62 @@ +"""Add status and thread_id to podcasts + +Revision ID: 82 +Revises: 81 +Create Date: 2026-01-27 + +Adds status enum and thread_id FK to podcasts. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "82" +down_revision: str | None = "81" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TYPE podcast_status AS ENUM ('pending', 'generating', 'ready', 'failed'); + """ + ) + + op.execute( + """ + ALTER TABLE podcasts + ADD COLUMN IF NOT EXISTS status podcast_status NOT NULL DEFAULT 'ready'; + """ + ) + + op.execute( + """ + ALTER TABLE podcasts + ADD COLUMN IF NOT EXISTS thread_id INTEGER + REFERENCES new_chat_threads(id) ON DELETE SET NULL; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_podcasts_thread_id + ON podcasts(thread_id); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_podcasts_status + ON podcasts(status); + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_podcasts_status") + op.execute("DROP INDEX IF EXISTS ix_podcasts_thread_id") + op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS thread_id") + op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS status") + op.execute("DROP TYPE IF EXISTS podcast_status") diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 53e1b14bd..fda22aec3 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -120,6 +120,7 @@ async def create_surfsense_deep_agent( connector_service: ConnectorService, checkpointer: Checkpointer, user_id: str | None = None, + thread_id: int | None = None, agent_config: AgentConfig | None = None, enabled_tools: list[str] | None = None, disabled_tools: list[str] | None = None, @@ -232,6 +233,7 @@ async def create_surfsense_deep_agent( "connector_service": connector_service, "firecrawl_api_key": firecrawl_api_key, "user_id": user_id, # Required for memory tools + "thread_id": thread_id, # For podcast tool # Dynamic connector/document type discovery for knowledge base tool "available_connectors": available_connectors, "available_document_types": available_document_types, diff --git a/surfsense_backend/app/agents/new_chat/tools/podcast.py b/surfsense_backend/app/agents/new_chat/tools/podcast.py index ff567bf73..424b04f77 100644 --- a/surfsense_backend/app/agents/new_chat/tools/podcast.py +++ b/surfsense_backend/app/agents/new_chat/tools/podcast.py @@ -18,6 +18,8 @@ import redis from langchain_core.tools import tool from sqlalchemy.ext.asyncio import AsyncSession +from app.db import Podcast, PodcastStatus + # Redis connection for tracking active podcast tasks # Uses the same Redis instance as Celery REDIS_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") @@ -32,50 +34,44 @@ def get_redis_client() -> redis.Redis: return _redis_client -def get_active_podcast_key(search_space_id: int) -> str: - """Generate Redis key for tracking active podcast task.""" - return f"podcast:active:{search_space_id}" +def _redis_key(search_space_id: int) -> str: + return f"podcast:generating:{search_space_id}" -def get_active_podcast_task(search_space_id: int) -> str | None: - """Check if there's an active podcast task for this search space.""" +def get_generating_podcast_id(search_space_id: int) -> int | None: + """Get the podcast ID currently being generated for this search space.""" try: client = get_redis_client() - return client.get(get_active_podcast_key(search_space_id)) + value = client.get(_redis_key(search_space_id)) + return int(value) if value else None except Exception: - # If Redis is unavailable, allow the request (fail open) return None -def set_active_podcast_task(search_space_id: int, task_id: str) -> None: - """Mark a podcast task as active for this search space.""" +def set_generating_podcast(search_space_id: int, podcast_id: int) -> None: + """Mark a podcast as currently generating for this search space.""" try: client = get_redis_client() - # Set with 30-minute expiry as safety net (podcast should complete before this) - client.setex(get_active_podcast_key(search_space_id), 1800, task_id) + client.setex(_redis_key(search_space_id), 1800, str(podcast_id)) except Exception as e: - print(f"[generate_podcast] Warning: Could not set active task in Redis: {e}") - - -def clear_active_podcast_task(search_space_id: int) -> None: - """Clear the active podcast task for this search space.""" - try: - client = get_redis_client() - client.delete(get_active_podcast_key(search_space_id)) - except Exception as e: - print(f"[generate_podcast] Warning: Could not clear active task in Redis: {e}") + print(f"[generate_podcast] Warning: Could not set generating podcast in Redis: {e}") def create_generate_podcast_tool( search_space_id: int, db_session: AsyncSession, + thread_id: int | None = None, ): """ Factory function to create the generate_podcast tool with injected dependencies. + Pre-creates podcast record with pending status so podcast_id is available + immediately for frontend polling. + Args: search_space_id: The user's search space ID - db_session: Database session (not used - Celery creates its own) + db_session: Database session for creating the podcast record + thread_id: The chat thread ID for associating the podcast Returns: A configured tool function for generating podcasts @@ -98,76 +94,71 @@ def create_generate_podcast_tool( - "Make a podcast about..." - "Turn this into a podcast" - The tool will start generating a podcast in the background. - The podcast will be available once generation completes. - - IMPORTANT: Only one podcast can be generated at a time. If a podcast - is already being generated, this tool will return a message asking - the user to wait. - Args: source_content: The text content to convert into a podcast. - This can be a summary, research findings, or any text - the user wants transformed into an audio podcast. podcast_title: Title for the podcast (default: "SurfSense Podcast") user_prompt: Optional instructions for podcast style, tone, or format. - For example: "Make it casual and fun" or "Focus on the key insights" Returns: A dictionary containing: - - status: "processing" (task submitted), "already_generating", or "error" - - task_id: The Celery task ID for polling status (if processing) + - status: PodcastStatus value (pending, generating, or failed) + - podcast_id: The podcast ID for polling (when status is pending or generating) - title: The podcast title - - message: Status message for the user + - message: Status message (or "error" field if status is failed) """ try: - # Check if a podcast is already being generated for this search space - active_task_id = get_active_podcast_task(search_space_id) - if active_task_id: + generating_podcast_id = get_generating_podcast_id(search_space_id) + if generating_podcast_id: print( - f"[generate_podcast] Blocked duplicate request. Active task: {active_task_id}" + f"[generate_podcast] Blocked duplicate request. Generating podcast: {generating_podcast_id}" ) return { - "status": "already_generating", - "task_id": active_task_id, + "status": PodcastStatus.GENERATING.value, + "podcast_id": generating_podcast_id, "title": podcast_title, - "message": "A podcast is already being generated. Please wait for it to complete before requesting another one.", + "message": "A podcast is already being generated. Please wait for it to complete.", } - # Import Celery task here to avoid circular imports + podcast = Podcast( + title=podcast_title, + status=PodcastStatus.PENDING, + search_space_id=search_space_id, + thread_id=thread_id, + ) + db_session.add(podcast) + await db_session.commit() + await db_session.refresh(podcast) + from app.tasks.celery_tasks.podcast_tasks import ( generate_content_podcast_task, ) - # Submit Celery task for background processing task = generate_content_podcast_task.delay( + podcast_id=podcast.id, source_content=source_content, search_space_id=search_space_id, - podcast_title=podcast_title, user_prompt=user_prompt, ) - # Mark this task as active - set_active_podcast_task(search_space_id, task.id) + set_generating_podcast(search_space_id, podcast.id) - print(f"[generate_podcast] Submitted Celery task: {task.id}") + print(f"[generate_podcast] Created podcast {podcast.id}, task: {task.id}") - # Return immediately with task_id for polling return { - "status": "processing", - "task_id": task.id, + "status": PodcastStatus.PENDING.value, + "podcast_id": podcast.id, "title": podcast_title, "message": "Podcast generation started. This may take a few minutes.", } except Exception as e: error_message = str(e) - print(f"[generate_podcast] Error submitting task: {error_message}") + print(f"[generate_podcast] Error: {error_message}") return { - "status": "error", + "status": PodcastStatus.FAILED.value, "error": error_message, "title": podcast_title, - "task_id": None, + "podcast_id": None, } return generate_podcast diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 968e51445..c65445419 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -107,8 +107,9 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ factory=lambda deps: create_generate_podcast_tool( search_space_id=deps["search_space_id"], db_session=deps["db_session"], + thread_id=deps["thread_id"], ), - requires=["search_space_id", "db_session"], + requires=["search_space_id", "db_session", "thread_id"], ), # Link preview tool - fetches Open Graph metadata for URLs ToolDefinition( diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 8a9507e1b..8c6942e44 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -93,6 +93,13 @@ class SearchSourceConnectorType(str, Enum): COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" +class PodcastStatus(str, Enum): + PENDING = "pending" + GENERATING = "generating" + READY = "ready" + FAILED = "failed" + + class LiteLLMProvider(str, Enum): """ Enum for LLM providers supported by LiteLLM. @@ -397,6 +404,47 @@ 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, + ForeignKey("new_chat_threads.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + cloned_at = Column( + TIMESTAMP(timezone=True), + nullable=True, + ) + # Flag to bootstrap LangGraph checkpointer with DB messages on first message + needs_history_bootstrap = Column( + Boolean, + nullable=False, + default=False, + server_default="false", + ) + # 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") created_by = relationship("User", back_populates="new_chat_threads") @@ -709,14 +757,34 @@ class Podcast(BaseModel, TimestampMixin): __tablename__ = "podcasts" title = Column(String(500), nullable=False) - podcast_transcript = Column(JSONB, nullable=True) # List of transcript entries - file_location = Column(Text, nullable=True) # Path to the audio file + podcast_transcript = Column(JSONB, nullable=True) + file_location = Column(Text, nullable=True) + status = Column( + SQLAlchemyEnum( + PodcastStatus, + name="podcast_status", + create_type=False, + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + default=PodcastStatus.READY, + server_default="ready", + index=True, + ) search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False ) search_space = relationship("SearchSpace", back_populates="podcasts") + thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + thread = relationship("NewChatThread") + class SearchSpace(BaseModel, TimestampMixin): __tablename__ = "searchspaces" diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 753105c46..746c18c6d 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -31,6 +31,7 @@ from .notes_routes import router as notes_router from .notifications_routes import router as notifications_router from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router +from .public_chat_routes import router as public_chat_router from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router @@ -68,4 +69,5 @@ router.include_router(circleback_webhook_router) # Circleback meeting webhooks router.include_router(surfsense_docs_router) # Surfsense documentation for citations router.include_router(notifications_router) # Notifications with Electric SQL sync router.include_router(composio_router) # Composio OAuth and toolkit management +router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 7631ec7eb..541e25a75 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -37,6 +37,7 @@ from app.db import ( get_async_session, ) from app.schemas.new_chat import ( + CompleteCloneResponse, NewChatMessageAppend, NewChatMessageRead, NewChatRequest, @@ -45,11 +46,14 @@ from app.schemas.new_chat import ( NewChatThreadUpdate, NewChatThreadVisibilityUpdate, NewChatThreadWithMessages, + PublicShareToggleRequest, + PublicShareToggleResponse, RegenerateRequest, ThreadHistoryLoadResponse, ThreadListItem, ThreadListResponse, ) +from app.services.public_chat_service import toggle_public_share from app.tasks.chat.stream_new_chat import stream_new_chat from app.users import current_active_user from app.utils.rbac import check_permission @@ -215,6 +219,7 @@ async def list_threads( visibility=thread.visibility, created_by_id=thread.created_by_id, is_own_thread=is_own_thread, + public_share_enabled=thread.public_share_enabled, created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -316,6 +321,7 @@ async def search_threads( thread.created_by_id == user.id or (thread.created_by_id is None and is_search_space_owner) ), + public_share_enabled=thread.public_share_enabled, created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -664,6 +670,62 @@ 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, @@ -729,6 +791,32 @@ async def update_thread_visibility( ) from None +@router.patch( + "/threads/{thread_id}/public-share", response_model=PublicShareToggleResponse +) +async def update_thread_public_share( + thread_id: int, + request: Request, + toggle_request: PublicShareToggleRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Enable or disable public sharing for a thread. + + Only the creator of the thread can manage public sharing. + When enabled, returns a public URL that anyone can use to view the chat. + """ + base_url = str(request.base_url).rstrip("/") + return await toggle_public_share( + session=session, + thread_id=thread_id, + enabled=toggle_request.enabled, + user=user, + base_url=base_url, + ) + + # ============================================================================= # Message Endpoints # ============================================================================= @@ -996,6 +1084,7 @@ async def handle_new_chat( attachments=request.attachments, mentioned_document_ids=request.mentioned_document_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, + needs_history_bootstrap=thread.needs_history_bootstrap, ), media_type="text/event-stream", headers={ @@ -1223,6 +1312,7 @@ async def regenerate_response( mentioned_document_ids=request.mentioned_document_ids, mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, checkpoint_id=target_checkpoint_id, + needs_history_bootstrap=thread.needs_history_bootstrap, ): yield chunk # If we get here, streaming completed successfully diff --git a/surfsense_backend/app/routes/podcasts_routes.py b/surfsense_backend/app/routes/podcasts_routes.py index ef362edb5..fa8326096 100644 --- a/surfsense_backend/app/routes/podcasts_routes.py +++ b/surfsense_backend/app/routes/podcasts_routes.py @@ -1,21 +1,19 @@ """ -Podcast routes for task status polling and audio retrieval. +Podcast routes for CRUD operations and audio streaming. These routes support the podcast generation feature in new-chat. -Note: The old Chat-based podcast generation has been removed. +Frontend polls GET /podcasts/{podcast_id} to check status field. """ import os from pathlib import Path -from celery.result import AsyncResult from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.celery_app import celery_app from app.db import ( Permission, Podcast, @@ -25,7 +23,7 @@ from app.db import ( get_async_session, ) from app.schemas import PodcastRead -from app.users import current_active_user +from app.users import current_active_user, current_optional_user from app.utils.rbac import check_permission router = APIRouter() @@ -84,12 +82,17 @@ async def read_podcasts( async def read_podcast( podcast_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User | None = Depends(current_optional_user), ): """ Get a specific podcast by ID. - Requires PODCASTS_READ permission for the search space. + + Access is allowed if: + - User is authenticated with PODCASTS_READ permission, OR + - Podcast belongs to a publicly shared thread """ + from app.services.public_chat_service import is_podcast_publicly_accessible + try: result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) podcast = result.scalars().first() @@ -100,16 +103,20 @@ async def read_podcast( detail="Podcast not found", ) - # Check permission for the search space - await check_permission( - session, - user, - podcast.search_space_id, - Permission.PODCASTS_READ.value, - "You don't have permission to read podcasts in this search space", - ) + is_public = await is_podcast_publicly_accessible(session, podcast_id) - return podcast + if not is_public: + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + await check_permission( + session, + user, + podcast.search_space_id, + Permission.PODCASTS_READ.value, + "You don't have permission to read podcasts in this search space", + ) + + return PodcastRead.from_orm_with_entries(podcast) except HTTPException as he: raise he except SQLAlchemyError: @@ -161,46 +168,49 @@ async def delete_podcast( async def stream_podcast( podcast_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + user: User | None = Depends(current_optional_user), ): """ Stream a podcast audio file. - Requires PODCASTS_READ permission for the search space. + + Access is allowed if: + - User is authenticated with PODCASTS_READ permission, OR + - Podcast belongs to a publicly shared thread Note: Both /stream and /audio endpoints are supported for compatibility. """ + from app.services.public_chat_service import is_podcast_publicly_accessible + try: result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) podcast = result.scalars().first() if not podcast: - raise HTTPException( - status_code=404, - detail="Podcast not found", + raise HTTPException(status_code=404, detail="Podcast not found") + + is_public = await is_podcast_publicly_accessible(session, podcast_id) + + if not is_public: + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + await check_permission( + session, + user, + podcast.search_space_id, + Permission.PODCASTS_READ.value, + "You don't have permission to access podcasts in this search space", ) - # Check permission for the search space - await check_permission( - session, - user, - podcast.search_space_id, - Permission.PODCASTS_READ.value, - "You don't have permission to access podcasts in this search space", - ) - - # Get the file path file_path = podcast.file_location - # Check if the file exists if not file_path or not os.path.isfile(file_path): raise HTTPException(status_code=404, detail="Podcast audio file not found") - # Define a generator function to stream the file def iterfile(): with open(file_path, mode="rb") as file_like: yield from file_like - # Return a streaming response with appropriate headers return StreamingResponse( iterfile(), media_type="audio/mpeg", @@ -216,62 +226,3 @@ async def stream_podcast( raise HTTPException( status_code=500, detail=f"Error streaming podcast: {e!s}" ) from e - - -@router.get("/podcasts/task/{task_id}/status") -async def get_podcast_task_status( - task_id: str, - user: User = Depends(current_active_user), -): - """ - Get the status of a podcast generation task. - Used by new-chat frontend to poll for completion. - - Returns: - - status: "processing" | "success" | "error" - - podcast_id: (only if status == "success") - - title: (only if status == "success") - - error: (only if status == "error") - """ - try: - result = AsyncResult(task_id, app=celery_app) - - if result.ready(): - # Task completed - if result.successful(): - task_result = result.result - if isinstance(task_result, dict): - if task_result.get("status") == "success": - return { - "status": "success", - "podcast_id": task_result.get("podcast_id"), - "title": task_result.get("title"), - "transcript_entries": task_result.get("transcript_entries"), - } - else: - return { - "status": "error", - "error": task_result.get("error", "Unknown error"), - } - else: - return { - "status": "error", - "error": "Unexpected task result format", - } - else: - # Task failed - return { - "status": "error", - "error": str(result.result) if result.result else "Task failed", - } - else: - # Task still processing - return { - "status": "processing", - "state": result.state, - } - - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Error checking task status: {e!s}" - ) from e diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py new file mode 100644 index 000000000..8b4f42559 --- /dev/null +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -0,0 +1,82 @@ +""" +Routes for public chat access (unauthenticated and mixed-auth endpoints). +""" + +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import ChatVisibility, NewChatThread, User, get_async_session +from app.schemas.new_chat import ( + CloneInitResponse, + PublicChatResponse, +) +from app.services.public_chat_service import ( + get_public_chat, + get_thread_by_share_token, + get_user_default_search_space, +) +from app.users import current_active_user + +router = APIRouter(prefix="/public", tags=["public"]) + + +@router.get("/{share_token}", response_model=PublicChatResponse) +async def read_public_chat( + share_token: str, + session: AsyncSession = Depends(get_async_session), +): + """ + Get a public chat by share token. + + No authentication required. + Returns sanitized content (citations stripped). + """ + return await get_public_chat(session, share_token) + + +@router.post("/{share_token}/clone", response_model=CloneInitResponse) +async def clone_public_chat_endpoint( + 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. + + Requires authentication. + """ + source_thread = await get_thread_by_share_token(session, share_token) + + 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) + + if target_search_space_id is None: + raise HTTPException(status_code=400, detail="No search space found for user") + + 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, + ) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 5070a2724..7d2cc5c77 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -123,7 +123,9 @@ async def list_all_permissions( for perm in Permission: # Extract category from permission value (e.g., "documents:read" -> "documents") category = perm.value.split(":")[0] if ":" in perm.value else "general" - description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}") + description = PERMISSION_DESCRIPTIONS.get( + perm.value, f"Permission for {perm.value}" + ) permissions.append( PermissionInfo( diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 7a29fc678..b420b1b91 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -95,6 +95,9 @@ 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 @@ -133,7 +136,8 @@ class ThreadListItem(BaseModel): archived: bool visibility: ChatVisibility created_by_id: UUID | None = None - is_own_thread: bool = False # True if the current user created this thread + is_own_thread: bool = False + public_share_enabled: bool = False created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") @@ -204,3 +208,63 @@ class RegenerateRequest(BaseModel): attachments: list[ChatAttachment] | None = None mentioned_document_ids: list[int] | None = None mentioned_surfsense_doc_ids: list[int] | None = None + + +# ============================================================================= +# Public Sharing Schemas +# ============================================================================= + + +class PublicShareToggleRequest(BaseModel): + """Request to enable/disable public sharing for a thread.""" + + enabled: bool + + +class PublicShareToggleResponse(BaseModel): + """Response after toggling public sharing.""" + + enabled: bool + public_url: str | None = None + share_token: str | None = None + + +# ============================================================================= +# Public Chat View Schemas (for unauthenticated access) +# ============================================================================= + + +class PublicAuthor(BaseModel): + display_name: str | None = None + avatar_url: str | None = None + + +class PublicChatMessage(BaseModel): + role: NewChatMessageRole + content: Any + author: PublicAuthor | None = None + created_at: datetime + + +class PublicChatThread(BaseModel): + title: str + created_at: datetime + + +class PublicChatResponse(BaseModel): + thread: PublicChatThread + messages: list[PublicChatMessage] + + +class CloneInitResponse(BaseModel): + + + thread_id: int + search_space_id: int + share_token: str + + +class CompleteCloneResponse(BaseModel): + + status: str + message_count: int diff --git a/surfsense_backend/app/schemas/podcasts.py b/surfsense_backend/app/schemas/podcasts.py index 72c915d88..9e5cb0262 100644 --- a/surfsense_backend/app/schemas/podcasts.py +++ b/surfsense_backend/app/schemas/podcasts.py @@ -1,11 +1,19 @@ """Podcast schemas for API responses.""" from datetime import datetime +from enum import Enum from typing import Any from pydantic import BaseModel +class PodcastStatusEnum(str, Enum): + PENDING = "pending" + GENERATING = "generating" + READY = "ready" + FAILED = "failed" + + class PodcastBase(BaseModel): """Base podcast schema.""" @@ -33,7 +41,24 @@ class PodcastRead(PodcastBase): """Schema for reading a podcast.""" id: int + status: PodcastStatusEnum = PodcastStatusEnum.READY created_at: datetime + transcript_entries: int | None = None class Config: from_attributes = True + + @classmethod + def from_orm_with_entries(cls, obj): + """Create PodcastRead with transcript_entries computed.""" + data = { + "id": obj.id, + "title": obj.title, + "podcast_transcript": obj.podcast_transcript, + "file_location": obj.file_location, + "search_space_id": obj.search_space_id, + "status": obj.status, + "created_at": obj.created_at, + "transcript_entries": len(obj.podcast_transcript) if obj.podcast_transcript else None, + } + return cls(**data) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py new file mode 100644 index 000000000..79618974f --- /dev/null +++ b/surfsense_backend/app/services/public_chat_service.py @@ -0,0 +1,376 @@ +""" +Service layer for public chat sharing and cloning. +""" + +import re +import secrets +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db import NewChatThread, User + +UI_TOOLS = { + "display_image", + "link_preview", + "generate_podcast", + "scrape_webpage", + "multi_link_preview", +} + + +def strip_citations(text: str) -> str: + """ + Remove [citation:X] and [citation:doc-X] patterns from text. + Preserves newlines to maintain markdown formatting. + """ + # Remove citation patterns + text = re.sub(r"[\[【]\u200B?citation:(doc-)?\d+\u200B?[\]】]", "", text) + # Collapse multiple spaces/tabs (but NOT newlines) into single space + text = re.sub(r"[^\S\n]+", " ", text) + # Normalize excessive blank lines (3+ newlines → 2) + text = re.sub(r"\n{3,}", "\n\n", text) + # Clean up spaces around newlines + text = re.sub(r" *\n *", "\n", text) + return text.strip() + + +def sanitize_content_for_public(content: list | str | None) -> list: + """ + Filter message content for public view. + Strips citations and filters to UI-relevant tools. + """ + if content is None: + return [] + + if isinstance(content, str): + clean_text = strip_citations(content) + return [{"type": "text", "text": clean_text}] if clean_text else [] + + if not isinstance(content, list): + return [] + + sanitized = [] + for part in content: + if not isinstance(part, dict): + continue + + part_type = part.get("type") + + if part_type == "text": + clean_text = strip_citations(part.get("text", "")) + if clean_text: + sanitized.append({"type": "text", "text": clean_text}) + + elif part_type == "tool-call": + tool_name = part.get("toolName") + if tool_name not in UI_TOOLS: + continue + sanitized.append(part) + + return sanitized + + +async def get_author_display( + session: AsyncSession, + author_id: UUID | None, + user_cache: dict[UUID, dict], +) -> dict | None: + """Transform author UUID to display info.""" + if author_id is None: + return None + + if author_id not in user_cache: + result = await session.execute(select(User).filter(User.id == author_id)) + user = result.scalars().first() + if user: + user_cache[author_id] = { + "display_name": user.display_name or "User", + "avatar_url": user.avatar_url, + } + else: + user_cache[author_id] = { + "display_name": "Unknown User", + "avatar_url": None, + } + + return user_cache[author_id] + + +async def toggle_public_share( + session: AsyncSession, + thread_id: int, + enabled: bool, + user: User, + base_url: str, +) -> dict: + """ + Enable or disable public sharing for a thread. + + Only the thread owner can toggle public sharing. + When enabling, generates a new token if one doesn't exist. + When disabling, keeps the token for potential re-enable. + """ + result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == thread_id) + ) + thread = result.scalars().first() + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + if thread.created_by_id != user.id: + raise HTTPException( + status_code=403, + detail="Only the creator of this chat can manage public sharing", + ) + + if enabled and not thread.public_share_token: + thread.public_share_token = secrets.token_urlsafe(48) + + thread.public_share_enabled = enabled + + await session.commit() + await session.refresh(thread) + + if enabled: + return { + "enabled": True, + "public_url": f"{base_url}/public/{thread.public_share_token}", + "share_token": thread.public_share_token, + } + + return { + "enabled": False, + "public_url": None, + "share_token": None, + } + + +async def get_public_chat( + session: AsyncSession, + share_token: str, +) -> dict: + """ + Get a public chat by share token. + + Returns sanitized content suitable for public viewing. + """ + result = await session.execute( + select(NewChatThread) + .options(selectinload(NewChatThread.messages)) + .filter( + NewChatThread.public_share_token == share_token, + NewChatThread.public_share_enabled.is_(True), + ) + ) + thread = result.scalars().first() + + if not thread: + raise HTTPException(status_code=404, detail="Not found") + + user_cache: dict[UUID, dict] = {} + + messages = [] + for msg in sorted(thread.messages, key=lambda m: m.created_at): + author = await get_author_display(session, msg.author_id, user_cache) + sanitized_content = sanitize_content_for_public(msg.content) + + messages.append( + { + "role": msg.role, + "content": sanitized_content, + "author": author, + "created_at": msg.created_at, + } + ) + + return { + "thread": { + "title": thread.title, + "created_at": thread.created_at, + }, + "messages": messages, + } + + +async def get_thread_by_share_token( + session: AsyncSession, + share_token: str, +) -> NewChatThread | None: + """Get a thread by its public share token if sharing is enabled.""" + result = await session.execute( + select(NewChatThread) + .options(selectinload(NewChatThread.messages)) + .filter( + NewChatThread.public_share_token == share_token, + NewChatThread.public_share_enabled.is_(True), + ) + ) + return result.scalars().first() + + +async def get_user_default_search_space( + session: AsyncSession, + user_id: UUID, +) -> int | None: + """ + Get user's default search space for cloning. + + Returns the first search space where user is owner, or None if not found. + """ + from app.db import SearchSpaceMembership + + result = await session.execute( + select(SearchSpaceMembership) + .filter( + SearchSpaceMembership.user_id == user_id, + SearchSpaceMembership.is_owner.is_(True), + ) + .limit(1) + ) + membership = result.scalars().first() + + if membership: + return membership.search_space_id + + return None + + +async def complete_clone_content( + session: AsyncSession, + target_thread: NewChatThread, + source_thread_id: int, + target_search_space_id: int, +) -> int: + """ + 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. + """ + from app.db import NewChatMessage + + result = await session.execute( + select(NewChatThread) + .options(selectinload(NewChatThread.messages)) + .filter(NewChatThread.id == source_thread_id) + ) + source_thread = result.scalars().first() + + 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] + + new_message = NewChatMessage( + thread_id=target_thread.id, + role=msg.role, + content=new_content, + author_id=msg.author_id, + created_at=msg.created_at, + ) + session.add(new_message) + message_count += 1 + + target_thread.clone_pending = False + target_thread.needs_history_bootstrap = True + + await session.commit() + + return message_count + + +async def _clone_podcast( + session: AsyncSession, + podcast_id: int, + target_search_space_id: int, + target_thread_id: int, +) -> int | None: + """Clone a podcast record and its audio file. Only clones ready podcasts.""" + import shutil + import uuid + from pathlib import Path + + from app.db import Podcast, PodcastStatus + + result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) + original = result.scalars().first() + if not original or original.status != PodcastStatus.READY: + return None + + new_file_path = None + if original.file_location: + original_path = Path(original.file_location) + if original_path.exists(): + new_filename = f"{uuid.uuid4()}_podcast.mp3" + new_dir = Path("podcasts") + new_dir.mkdir(parents=True, exist_ok=True) + new_file_path = str(new_dir / new_filename) + shutil.copy2(original.file_location, new_file_path) + + new_podcast = Podcast( + title=original.title, + podcast_transcript=original.podcast_transcript, + file_location=new_file_path, + status=PodcastStatus.READY, + search_space_id=target_search_space_id, + thread_id=target_thread_id, + ) + session.add(new_podcast) + await session.flush() + + 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 diff --git a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py index 34b9b827c..0ce714cdc 100644 --- a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py @@ -4,15 +4,15 @@ import asyncio import logging import sys +from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool -# Import for content-based podcast (new-chat) from app.agents.podcaster.graph import graph as podcaster_graph from app.agents.podcaster.state import State as PodcasterState from app.celery_app import celery_app from app.config import config -from app.db import Podcast +from app.db import Podcast, PodcastStatus logger = logging.getLogger(__name__) @@ -44,8 +44,8 @@ def get_celery_session_maker(): # ============================================================================= -def _clear_active_podcast_redis_key(search_space_id: int) -> None: - """Clear the active podcast task key from Redis when task completes.""" +def _clear_generating_podcast(search_space_id: int) -> None: + """Clear the generating podcast marker from Redis when task completes.""" import os import redis @@ -53,34 +53,24 @@ def _clear_active_podcast_redis_key(search_space_id: int) -> None: try: redis_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") client = redis.from_url(redis_url, decode_responses=True) - key = f"podcast:active:{search_space_id}" + key = f"podcast:generating:{search_space_id}" client.delete(key) - logger.info(f"Cleared active podcast key for search_space_id={search_space_id}") + logger.info(f"Cleared generating podcast key for search_space_id={search_space_id}") except Exception as e: - logger.warning(f"Could not clear active podcast key: {e}") + logger.warning(f"Could not clear generating podcast key: {e}") @celery_app.task(name="generate_content_podcast", bind=True) def generate_content_podcast_task( self, + podcast_id: int, source_content: str, search_space_id: int, - podcast_title: str = "SurfSense Podcast", user_prompt: str | None = None, ) -> dict: """ - Celery task to generate podcast from source content (for new-chat). - - This task generates a podcast directly from provided content. - - Args: - source_content: The text content to convert into a podcast - search_space_id: ID of the search space - podcast_title: Title for the podcast - user_prompt: Optional instructions for podcast style/tone - - Returns: - dict with podcast_id on success, or error info on failure + Celery task to generate podcast from source content. + Updates existing podcast record created by the tool. """ loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -88,9 +78,9 @@ def generate_content_podcast_task( try: result = loop.run_until_complete( _generate_content_podcast( + podcast_id, source_content, search_space_id, - podcast_title, user_prompt, ) ) @@ -98,46 +88,69 @@ def generate_content_podcast_task( return result except Exception as e: logger.error(f"Error generating content podcast: {e!s}") - return {"status": "error", "error": str(e)} + loop.run_until_complete(_mark_podcast_failed(podcast_id)) + return {"status": "failed", "podcast_id": podcast_id} finally: - # Always clear the active podcast key when task completes (success or failure) - _clear_active_podcast_redis_key(search_space_id) + _clear_generating_podcast(search_space_id) asyncio.set_event_loop(None) loop.close() -async def _generate_content_podcast( - source_content: str, - search_space_id: int, - podcast_title: str = "SurfSense Podcast", - user_prompt: str | None = None, -) -> dict: - """Generate content-based podcast with new session.""" +async def _mark_podcast_failed(podcast_id: int) -> None: + """Mark a podcast as failed in the database.""" async with get_celery_session_maker()() as session: try: - # Configure the podcaster graph + result = await session.execute( + select(Podcast).filter(Podcast.id == podcast_id) + ) + podcast = result.scalars().first() + if podcast: + podcast.status = PodcastStatus.FAILED + await session.commit() + except Exception as e: + logger.error(f"Failed to mark podcast as failed: {e}") + + +async def _generate_content_podcast( + podcast_id: int, + source_content: str, + search_space_id: int, + user_prompt: str | None = None, +) -> dict: + """Generate content-based podcast and update existing record.""" + async with get_celery_session_maker()() as session: + result = await session.execute( + select(Podcast).filter(Podcast.id == podcast_id) + ) + podcast = result.scalars().first() + + if not podcast: + raise ValueError(f"Podcast {podcast_id} not found") + + try: + podcast.status = PodcastStatus.GENERATING + await session.commit() + graph_config = { "configurable": { - "podcast_title": podcast_title, + "podcast_title": podcast.title, "search_space_id": search_space_id, "user_prompt": user_prompt, } } - # Initialize the podcaster state with the source content initial_state = PodcasterState( source_content=source_content, db_session=session, ) - # Run the podcaster graph - result = await podcaster_graph.ainvoke(initial_state, config=graph_config) + graph_result = await podcaster_graph.ainvoke( + initial_state, config=graph_config + ) - # Extract results - podcast_transcript = result.get("podcast_transcript", []) - file_path = result.get("final_podcast_file_path", "") + podcast_transcript = graph_result.get("podcast_transcript", []) + file_path = graph_result.get("final_podcast_file_path", "") - # Convert transcript to serializable format serializable_transcript = [] for entry in podcast_transcript: if hasattr(entry, "speaker_id"): @@ -152,27 +165,22 @@ async def _generate_content_podcast( } ) - # Save podcast to database - podcast = Podcast( - title=podcast_title, - podcast_transcript=serializable_transcript, - file_location=file_path, - search_space_id=search_space_id, - ) - session.add(podcast) + podcast.podcast_transcript = serializable_transcript + podcast.file_location = file_path + podcast.status = PodcastStatus.READY await session.commit() - await session.refresh(podcast) - logger.info(f"Successfully generated content podcast: {podcast.id}") + logger.info(f"Successfully generated podcast: {podcast.id}") return { - "status": "success", + "status": "ready", "podcast_id": podcast.id, - "title": podcast_title, + "title": podcast.title, "transcript_entries": len(serializable_transcript), } except Exception as e: logger.error(f"Error in _generate_content_podcast: {e!s}") - await session.rollback() + podcast.status = PodcastStatus.FAILED + await session.commit() raise diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 9a4f050a1..12d7cbd4e 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -34,6 +34,7 @@ from app.services.chat_session_state_service import ( ) from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService +from app.utils.content_utils import bootstrap_history_from_db def format_attachments_as_context(attachments: list[ChatAttachment]) -> str: @@ -205,13 +206,13 @@ async def stream_new_chat( mentioned_document_ids: list[int] | None = None, mentioned_surfsense_doc_ids: list[int] | None = None, checkpoint_id: str | None = None, + needs_history_bootstrap: bool = False, ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. This uses the Vercel AI SDK Data Stream Protocol (SSE format) for streaming. The chat_id is used as LangGraph's thread_id for memory/checkpointing. - Message history can be passed from the frontend for context. Args: user_query: The user's query @@ -221,6 +222,7 @@ async def stream_new_chat( user_id: The current user's UUID string (for memory tools and session state) llm_config_id: The LLM configuration ID (default: -1 for first global config) attachments: Optional attachments with extracted content + needs_history_bootstrap: If True, load message history from DB (for cloned chats) mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat checkpoint_id: Optional checkpoint ID to rewind/fork from (for edit/reload operations) @@ -300,13 +302,29 @@ async def stream_new_chat( connector_service=connector_service, checkpointer=checkpointer, user_id=user_id, # Pass user ID for memory tools + thread_id=chat_id, # Pass chat ID for podcast association agent_config=agent_config, # Pass prompt configuration firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured ) - # Build input with message history from frontend + # Build input with message history langchain_messages = [] + # Bootstrap history for cloned chats (no LangGraph checkpoint exists yet) + if needs_history_bootstrap: + langchain_messages = await bootstrap_history_from_db(session, chat_id) + + # Clear the flag so we don't bootstrap again on next message + from app.db import NewChatThread + + thread_result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + thread = thread_result.scalars().first() + if thread: + thread.needs_history_bootstrap = False + await session.commit() + # Fetch mentioned documents if any (with chunks for proper citations) mentioned_documents: list[Document] = [] if mentioned_document_ids: diff --git a/surfsense_backend/app/tasks/document_processors/file_processors.py b/surfsense_backend/app/tasks/document_processors/file_processors.py index 0a22c20c2..5161fb569 100644 --- a/surfsense_backend/app/tasks/document_processors/file_processors.py +++ b/surfsense_backend/app/tasks/document_processors/file_processors.py @@ -37,18 +37,30 @@ from .base import ( from .markdown_processor import add_received_markdown_file_document # Constants for LlamaCloud retry configuration -LLAMACLOUD_MAX_RETRIES = 3 -LLAMACLOUD_BASE_DELAY = 5 # Base delay in seconds for exponential backoff +LLAMACLOUD_MAX_RETRIES = 5 # Increased from 3 for large file resilience +LLAMACLOUD_BASE_DELAY = 10 # Base delay in seconds for exponential backoff +LLAMACLOUD_MAX_DELAY = 120 # Maximum delay between retries (2 minutes) LLAMACLOUD_RETRYABLE_EXCEPTIONS = ( ssl.SSLError, httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, + httpx.RemoteProtocolError, + httpx.LocalProtocolError, ConnectionError, + ConnectionResetError, TimeoutError, + OSError, # Catches various network-level errors ) +# Timeout calculation constants +UPLOAD_BYTES_PER_SECOND_SLOW = 100 * 1024 # 100 KB/s (conservative for slow connections) +MIN_UPLOAD_TIMEOUT = 120 # Minimum 2 minutes for any file +MAX_UPLOAD_TIMEOUT = 1800 # Maximum 30 minutes for very large files +BASE_JOB_TIMEOUT = 600 # 10 minutes base for job processing +PER_PAGE_JOB_TIMEOUT = 60 # 1 minute per page for processing + def get_google_drive_unique_identifier( connector: dict | None, @@ -204,6 +216,48 @@ async def find_existing_document_with_migration( return existing_document +def calculate_upload_timeout(file_size_bytes: int) -> float: + """ + Calculate appropriate upload timeout based on file size. + + Assumes a conservative slow connection speed to handle worst-case scenarios. + + Args: + file_size_bytes: Size of the file in bytes + + Returns: + Timeout in seconds + """ + # Calculate time needed at slow connection speed + # Add 50% buffer for network variability and SSL overhead + estimated_time = (file_size_bytes / UPLOAD_BYTES_PER_SECOND_SLOW) * 1.5 + + # Clamp to reasonable bounds + return max(MIN_UPLOAD_TIMEOUT, min(estimated_time, MAX_UPLOAD_TIMEOUT)) + + +def calculate_job_timeout(estimated_pages: int, file_size_bytes: int) -> float: + """ + Calculate job processing timeout based on page count and file size. + + Args: + estimated_pages: Estimated number of pages + file_size_bytes: Size of the file in bytes + + Returns: + Timeout in seconds + """ + # Base timeout + time per page + page_based_timeout = BASE_JOB_TIMEOUT + (estimated_pages * PER_PAGE_JOB_TIMEOUT) + + # Also consider file size (large images take longer to process) + # ~1 minute per 10MB of file size + size_based_timeout = BASE_JOB_TIMEOUT + (file_size_bytes / (10 * 1024 * 1024)) * 60 + + # Use the larger of the two estimates + return max(page_based_timeout, size_based_timeout) + + async def parse_with_llamacloud_retry( file_path: str, estimated_pages: int, @@ -213,6 +267,9 @@ async def parse_with_llamacloud_retry( """ Parse a file with LlamaCloud with retry logic for transient SSL/connection errors. + Uses dynamic timeout calculations based on file size and page count to handle + very large files reliably. + Args: file_path: Path to the file to parse estimated_pages: Estimated number of pages for timeout calculation @@ -225,25 +282,37 @@ async def parse_with_llamacloud_retry( Raises: Exception: If all retries fail """ + import os + import random + from llama_cloud_services import LlamaParse from llama_cloud_services.parse.utils import ResultType - # Calculate timeouts based on estimated pages - # Base timeout of 300 seconds + 30 seconds per page for large documents - base_timeout = 300 - per_page_timeout = 30 - job_timeout = base_timeout + (estimated_pages * per_page_timeout) - - # Create custom httpx client with larger timeouts for file uploads - # The SSL error often occurs during large file uploads, so we need generous timeouts + # Get file size for timeout calculations + file_size_bytes = os.path.getsize(file_path) + file_size_mb = file_size_bytes / (1024 * 1024) + + # Calculate dynamic timeouts based on file size and page count + upload_timeout = calculate_upload_timeout(file_size_bytes) + job_timeout = calculate_job_timeout(estimated_pages, file_size_bytes) + + # HTTP client timeouts - scaled based on file size + # Write timeout is critical for large file uploads custom_timeout = httpx.Timeout( - connect=60.0, # 60 seconds to establish connection - read=300.0, # 5 minutes to read response - write=300.0, # 5 minutes to write/upload (important for large files) - pool=60.0, # 60 seconds to acquire connection from pool + connect=120.0, # 2 minutes to establish connection (handles slow DNS, etc.) + read=upload_timeout, # Dynamic based on file size + write=upload_timeout, # Dynamic based on file size (upload time) + pool=120.0, # 2 minutes to acquire connection from pool + ) + + logging.info( + f"LlamaCloud upload configured: file_size={file_size_mb:.1f}MB, " + f"pages={estimated_pages}, upload_timeout={upload_timeout:.0f}s, " + f"job_timeout={job_timeout:.0f}s" ) last_exception = None + attempt_errors = [] for attempt in range(1, LLAMACLOUD_MAX_RETRIES + 1): try: @@ -257,46 +326,67 @@ async def parse_with_llamacloud_retry( language="en", result_type=ResultType.MD, # Timeout settings for large files - max_timeout=max(2000, job_timeout), # Overall max timeout + max_timeout=int(max(2000, job_timeout + upload_timeout)), job_timeout_in_seconds=job_timeout, - job_timeout_extra_time_per_page_in_seconds=per_page_timeout, + job_timeout_extra_time_per_page_in_seconds=PER_PAGE_JOB_TIMEOUT, # Use our custom client with larger timeouts custom_client=custom_client, ) # Parse the file asynchronously result = await parser.aparse(file_path) + + # Success - log if we had previous failures + if attempt > 1: + logging.info( + f"LlamaCloud upload succeeded on attempt {attempt} after " + f"{len(attempt_errors)} failures" + ) + return result except LLAMACLOUD_RETRYABLE_EXCEPTIONS as e: last_exception = e error_type = type(e).__name__ + error_msg = str(e)[:200] + attempt_errors.append(f"Attempt {attempt}: {error_type} - {error_msg}") if attempt < LLAMACLOUD_MAX_RETRIES: - # Calculate exponential backoff delay - delay = LLAMACLOUD_BASE_DELAY * (2 ** (attempt - 1)) + # Calculate exponential backoff with jitter + # Base delay doubles each attempt, capped at max delay + base_delay = min( + LLAMACLOUD_BASE_DELAY * (2 ** (attempt - 1)), + LLAMACLOUD_MAX_DELAY + ) + # Add random jitter (±25%) to prevent thundering herd + jitter = base_delay * 0.25 * (2 * random.random() - 1) + delay = base_delay + jitter if task_logger and log_entry: await task_logger.log_task_progress( log_entry, - f"LlamaCloud upload failed (attempt {attempt}/{LLAMACLOUD_MAX_RETRIES}), retrying in {delay}s", + f"LlamaCloud upload failed (attempt {attempt}/{LLAMACLOUD_MAX_RETRIES}), retrying in {delay:.0f}s", { "error_type": error_type, - "error_message": str(e)[:200], + "error_message": error_msg, "attempt": attempt, "retry_delay": delay, + "file_size_mb": round(file_size_mb, 1), + "upload_timeout": upload_timeout, }, ) else: logging.warning( - f"LlamaCloud upload failed (attempt {attempt}/{LLAMACLOUD_MAX_RETRIES}): {error_type}. " - f"Retrying in {delay}s..." + f"LlamaCloud upload failed (attempt {attempt}/{LLAMACLOUD_MAX_RETRIES}): " + f"{error_type}. File: {file_size_mb:.1f}MB. Retrying in {delay:.0f}s..." ) await asyncio.sleep(delay) else: logging.error( - f"LlamaCloud upload failed after {LLAMACLOUD_MAX_RETRIES} attempts: {error_type} - {e}" + f"LlamaCloud upload failed after {LLAMACLOUD_MAX_RETRIES} attempts. " + f"File size: {file_size_mb:.1f}MB, Pages: {estimated_pages}. " + f"Errors: {'; '.join(attempt_errors)}" ) except Exception: @@ -304,7 +394,10 @@ async def parse_with_llamacloud_retry( raise # All retries exhausted - raise last_exception or RuntimeError("LlamaCloud parsing failed after all retries") + raise last_exception or RuntimeError( + f"LlamaCloud parsing failed after {LLAMACLOUD_MAX_RETRIES} retries. " + f"File size: {file_size_mb:.1f}MB" + ) async def add_received_file_document_using_unstructured( diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index e86eb752b..4be2fe525 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -229,3 +229,4 @@ auth_backend = AuthenticationBackend( fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) current_active_user = fastapi_users.current_user(active=True) +current_optional_user = fastapi_users.current_user(active=True, optional=True) diff --git a/surfsense_backend/app/utils/content_utils.py b/surfsense_backend/app/utils/content_utils.py new file mode 100644 index 000000000..d2342b79e --- /dev/null +++ b/surfsense_backend/app/utils/content_utils.py @@ -0,0 +1,75 @@ +""" +Utilities for working with message content. + +Message content in new_chat_messages can be stored in various formats: +- String: Simple text content +- List: Array of content parts [{"type": "text", "text": "..."}, {"type": "tool-call", ...}] +- Dict: Single content object + +These utilities help extract and transform content for different use cases. +""" + +from langchain_core.messages import AIMessage, HumanMessage +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +def extract_text_content(content: str | dict | list) -> str: + """Extract plain text content from various message formats.""" + if isinstance(content, str): + return content + if isinstance(content, dict): + # Handle dict with 'text' key + if "text" in content: + return content["text"] + return str(content) + if isinstance(content, list): + # Handle list of parts (e.g., [{"type": "text", "text": "..."}]) + texts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + texts.append(part.get("text", "")) + elif isinstance(part, str): + texts.append(part) + return "\n".join(texts) if texts else "" + return "" + + +async def bootstrap_history_from_db( + session: AsyncSession, + thread_id: int, +) -> list[HumanMessage | AIMessage]: + """ + Load message history from database and convert to LangChain format. + + Used for cloned chats where the LangGraph checkpointer has no state, + but we have messages in the database that should be used as context. + + Args: + session: Database session + thread_id: The chat thread ID + + Returns: + List of LangChain messages (HumanMessage/AIMessage) + """ + from app.db import NewChatMessage + + result = await session.execute( + select(NewChatMessage) + .filter(NewChatMessage.thread_id == thread_id) + .order_by(NewChatMessage.created_at) + ) + db_messages = result.scalars().all() + + langchain_messages: list[HumanMessage | AIMessage] = [] + + for msg in db_messages: + text_content = extract_text_content(msg.content) + if not text_content: + continue + if msg.role == "user": + langchain_messages.append(HumanMessage(content=text_content)) + elif msg.role == "assistant": + langchain_messages.append(AIMessage(content=text_content)) + + return langchain_messages diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index 0dc9c445f..8b3be3805 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -27,6 +27,13 @@ function LoginContent() { const error = searchParams.get("error"); const message = searchParams.get("message"); const logout = searchParams.get("logout"); + const returnUrl = searchParams.get("returnUrl"); + + // Save returnUrl to localStorage so it persists through OAuth flows (e.g., Google) + // This is read by TokenHandler after successful authentication + if (returnUrl) { + localStorage.setItem("surfsense_redirect_path", decodeURIComponent(returnUrl)); + } // Show registration success message if (registered === "true") { @@ -93,7 +100,7 @@ function LoginContent() { }, [searchParams, t, tCommon]); // Use global loading screen for auth type determination - spinner animation won't reset - useGlobalLoadingEffect(isLoading, tCommon("loading"), "login"); + useGlobalLoadingEffect(isLoading); // Show nothing while loading - the GlobalLoadingProvider handles the loading UI if (isLoading) { diff --git a/surfsense_web/app/auth/callback/loading.tsx b/surfsense_web/app/auth/callback/loading.tsx index 0c94e1ee0..f12b3847d 100644 --- a/surfsense_web/app/auth/callback/loading.tsx +++ b/surfsense_web/app/auth/callback/loading.tsx @@ -1,13 +1,10 @@ "use client"; -import { useTranslations } from "next-intl"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; export default function AuthCallbackLoading() { - const t = useTranslations("auth"); - // Use global loading - spinner animation won't reset when page transitions - useGlobalLoadingEffect(true, t("processing_authentication"), "default"); + useGlobalLoadingEffect(true); // Return null - the GlobalLoadingProvider handles the loading UI return null; diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index e6730d8d1..8418d4719 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -154,11 +154,7 @@ export function DashboardClientLayout({ isAutoConfiguring; // Use global loading screen - spinner animation won't reset - useGlobalLoadingEffect( - shouldShowLoading, - isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"), - "default" - ); + useGlobalLoadingEffect(shouldShowLoading); if (shouldShowLoading) { return null; diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 1a00873a5..f6f70f83b 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,9 +42,11 @@ 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"; +import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { isPodcastGenerating, looksLikePodcastRequest, @@ -114,112 +116,6 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { return []; } -/** - * Zod schema for persisted attachment info - */ -const PersistedAttachmentSchema = z.object({ - id: z.string(), - name: z.string(), - type: z.string(), - contentType: z.string().optional(), - imageDataUrl: z.string().optional(), - extractedContent: z.string().optional(), -}); - -const AttachmentsPartSchema = z.object({ - type: z.literal("attachments"), - items: z.array(PersistedAttachmentSchema), -}); - -type PersistedAttachment = z.infer; - -/** - * Extract persisted attachments from message content (type-safe with Zod) - */ -function extractPersistedAttachments(content: unknown): PersistedAttachment[] { - if (!Array.isArray(content)) return []; - - for (const part of content) { - const result = AttachmentsPartSchema.safeParse(part); - if (result.success) { - return result.data.items; - } - } - - return []; -} - -/** - * Convert backend message to assistant-ui ThreadMessageLike format - * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps - * Restores attachments for user messages from persisted data - */ -function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { - let content: ThreadMessageLike["content"]; - - if (typeof msg.content === "string") { - content = [{ type: "text", text: msg.content }]; - } else if (Array.isArray(msg.content)) { - // Filter out custom metadata parts - they're handled separately - const filteredContent = msg.content.filter((part: unknown) => { - if (typeof part !== "object" || part === null || !("type" in part)) return true; - const partType = (part as { type: string }).type; - // Filter out thinking-steps, mentioned-documents, and attachments - return ( - partType !== "thinking-steps" && - partType !== "mentioned-documents" && - partType !== "attachments" - ); - }); - content = - filteredContent.length > 0 - ? (filteredContent as ThreadMessageLike["content"]) - : [{ type: "text", text: "" }]; - } else { - content = [{ type: "text", text: String(msg.content) }]; - } - - // Restore attachments for user messages - let attachments: ThreadMessageLike["attachments"]; - if (msg.role === "user") { - const persistedAttachments = extractPersistedAttachments(msg.content); - if (persistedAttachments.length > 0) { - attachments = persistedAttachments.map((att) => ({ - id: att.id, - name: att.name, - type: att.type as "document" | "image" | "file", - contentType: att.contentType || "application/octet-stream", - status: { type: "complete" as const }, - content: [], - // Custom fields for our ChatAttachment interface - imageDataUrl: att.imageDataUrl, - extractedContent: att.extractedContent, - })); - } - } - - // Build metadata.custom for author display in shared chats - const metadata = msg.author_id - ? { - custom: { - author: { - displayName: msg.author_display_name ?? null, - avatarUrl: msg.author_avatar_url ?? null, - }, - }, - } - : undefined; - - return { - id: `msg-${msg.id}`, - role: msg.role, - content, - createdAt: new Date(msg.created_at), - attachments, - metadata, - }; -} - /** * Tools that should render custom UI in the chat. */ @@ -246,6 +142,7 @@ export default function NewChatPage() { const params = useParams(); const queryClient = useQueryClient(); const [isInitializing, setIsInitializing] = useState(true); + const [isCompletingClone, setIsCompletingClone] = useState(false); const [threadId, setThreadId] = useState(null); const [currentThread, setCurrentThread] = useState(null); const [messages, setMessages] = useState([]); @@ -300,6 +197,12 @@ export default function NewChatPage() { ? membersData?.find((m) => m.user_id === msg.author_id) : null; + // Preserve existing author info if member lookup fails (e.g., cloned chats) + const existingMsg = prev.find((m) => m.id === `msg-${msg.id}`); + const existingAuthor = existingMsg?.metadata?.custom?.author as + | { displayName?: string | null; avatarUrl?: string | null } + | undefined; + return convertToThreadMessage({ id: msg.id, thread_id: msg.thread_id, @@ -307,8 +210,8 @@ export default function NewChatPage() { content: msg.content, author_id: msg.author_id, created_at: msg.created_at, - author_display_name: member?.user_display_name ?? null, - author_avatar_url: member?.user_avatar_url ?? null, + author_display_name: member?.user_display_name ?? existingAuthor?.displayName ?? null, + author_avatar_url: member?.user_avatar_url ?? existingAuthor?.avatarUrl ?? null, }); }); }); @@ -428,6 +331,34 @@ export default function NewChatPage() { initializeThread(); }, [initializeThread]); + // Handle clone completion when thread has clone_pending flag + useEffect(() => { + if (!currentThread?.clone_pending || isCompletingClone) 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."); + } finally { + setIsCompletingClone(false); + } + }; + + completeClone(); + }, [currentThread?.clone_pending, currentThread?.id, isCompletingClone, initializeThread, queryClient]); + // Handle scroll to comment from URL query params (e.g., from inbox item click) const searchParams = useSearchParams(); const targetCommentIdParam = searchParams.get("commentId"); @@ -454,6 +385,8 @@ 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]); @@ -880,13 +813,13 @@ export default function NewChatPage() { // Update the tool call with its result updateToolCall(parsed.toolCallId, { result: parsed.output }); // Handle podcast-specific logic - if (parsed.output?.status === "processing" && parsed.output?.task_id) { + if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { // Check if this is a podcast tool by looking at the content part const idx = toolCallIndices.get(parsed.toolCallId); if (idx !== undefined) { const part = contentParts[idx]; if (part?.type === "tool-call" && part.toolName === "generate_podcast") { - setActivePodcastTaskId(parsed.output.task_id); + setActivePodcastTaskId(String(parsed.output.podcast_id)); } } } @@ -1300,12 +1233,12 @@ export default function NewChatPage() { case "tool-output-available": updateToolCall(parsed.toolCallId, { result: parsed.output }); - if (parsed.output?.status === "processing" && parsed.output?.task_id) { + if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { const idx = toolCallIndices.get(parsed.toolCallId); if (idx !== undefined) { const part = contentParts[idx]; if (part?.type === "tool-call" && part.toolName === "generate_podcast") { - setActivePodcastTaskId(parsed.output.task_id); + setActivePodcastTaskId(String(parsed.output.podcast_id)); } } } @@ -1478,6 +1411,16 @@ 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/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 298871cf7..87e4281ae 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -115,13 +115,13 @@ import type { Membership, UpdateMembershipRequest, } from "@/contracts/types/members.types"; +import type { PermissionInfo } from "@/contracts/types/permissions.types"; import type { CreateRoleRequest, DeleteRoleRequest, Role, UpdateRoleRequest, } from "@/contracts/types/roles.types"; -import type { PermissionInfo } from "@/contracts/types/permissions.types"; import { invitesApiService } from "@/lib/apis/invites-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events"; @@ -980,11 +980,7 @@ function RolesTab({ > {/* Create Role Button / Section */} {canCreate && !showCreateRole && ( - + - - - {t("chats")} ({chats.length + sharedChats.length}) - - + {/* Chat sections - fills available space */} + {isCollapsed ? ( +
+ {(chats.length > 0 || sharedChats.length > 0) && ( + + + + + + {t("chats")} ({chats.length + sharedChats.length}) + + + )} +
+ ) : ( +
+ {/* Shared Chats Section - takes half the space */} + + + + + + {t("view_all_shared_chats") || "View all shared chats"} + + + ) : undefined + } + > + {sharedChats.length > 0 ? ( +
+
4 ? "pb-8" : ""}`} + > + {sharedChats.slice(0, 20).map((chat) => ( + onChatSelect(chat)} + onArchive={() => onChatArchive?.(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))} +
+ {/* Gradient fade indicator when more than 4 items */} + {sharedChats.length > 4 && ( +
+ )} +
+ ) : ( +

{t("no_shared_chats")}

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

{t("no_shared_chats")}

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

{t("no_chats")}

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

{t("no_chats")}

+ )} +
+
+ )} {/* Footer */}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index 0ceafc113..bebb357ef 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -11,6 +11,8 @@ interface SidebarSectionProps { children: React.ReactNode; action?: React.ReactNode; persistentAction?: React.ReactNode; + className?: string; + fillHeight?: boolean; } export function SidebarSection({ @@ -19,12 +21,18 @@ export function SidebarSection({ children, action, persistentAction, + className, + fillHeight = false, }: SidebarSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); return ( - -
+ +
- -
{children}
+ +
+ {children} +
); diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index fcace2572..9a1b3c426 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -2,9 +2,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { User, Users } from "lucide-react"; +import { Globe, Link2, User, Users } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; +import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -48,11 +49,19 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS // Use Jotai atom for visibility (single source of truth) const currentThreadState = useAtomValue(currentThreadAtom); + const setCurrentThreadState = useSetAtom(currentThreadAtom); const setThreadVisibility = useSetAtom(setThreadVisibilityAtom); + // Public share mutation + const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue( + togglePublicShareMutationAtom + ); + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; - const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it + const isPublicEnabled = + currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false; + const publicShareToken = currentThreadState.publicShareToken ?? null; const handleVisibilityChange = useCallback( async (newVisibility: ChatVisibility) => { @@ -87,12 +96,45 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility] ); + const handlePublicShareToggle = useCallback(async () => { + if (!thread) return; + + try { + const response = await togglePublicShare({ + thread_id: thread.id, + enabled: !isPublicEnabled, + }); + + // Update atom state with response + setCurrentThreadState((prev) => ({ + ...prev, + publicShareEnabled: response.enabled, + publicShareToken: response.share_token, + })); + } catch (error) { + console.error("Failed to toggle public share:", error); + } + }, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]); + + const handleCopyPublicLink = useCallback(async () => { + if (!publicShareToken) return; + + const publicUrl = `${window.location.origin}/public/${publicShareToken}`; + await navigator.clipboard.writeText(publicUrl); + toast.success("Public link copied to clipboard"); + }, [publicShareToken]); + // Don't show if no thread (new chat that hasn't been created yet) if (!thread) { return null; } - const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users; + const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users; + const buttonLabel = isPublicEnabled + ? "Public" + : currentVisibility === "PRIVATE" + ? "Private" + : "Shared"; return ( @@ -108,9 +150,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS )} > - - {currentVisibility === "PRIVATE" ? "Private" : "Shared"} - + {buttonLabel} @@ -124,6 +164,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS onCloseAutoFocus={(e) => e.preventDefault()} >
+ {/* Visibility Options */} {visibilityOptions.map((option) => { const isSelected = currentVisibility === option.value; const Icon = option.icon; @@ -166,6 +207,72 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS ); })} + + {/* Divider */} +
+ + {/* Public Share Option */} +
diff --git a/surfsense_web/components/providers/ElectricProvider.tsx b/surfsense_web/components/providers/ElectricProvider.tsx index 07d736c64..4aa83b304 100644 --- a/surfsense_web/components/providers/ElectricProvider.tsx +++ b/surfsense_web/components/providers/ElectricProvider.tsx @@ -1,7 +1,6 @@ "use client"; import { useAtomValue } from "jotai"; -import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; @@ -30,7 +29,6 @@ interface ElectricProviderProps { * 5. Provides client via context - hooks should use useElectricClient() */ export function ElectricProvider({ children }: ElectricProviderProps) { - const t = useTranslations("common"); const [electricClient, setElectricClient] = useState(null); const [error, setError] = useState(null); const { @@ -117,7 +115,7 @@ export function ElectricProvider({ children }: ElectricProviderProps) { const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error; // Use global loading hook with ownership tracking - prevents flash during transitions - useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default"); + useGlobalLoadingEffect(shouldShowLoading); // For non-authenticated pages (like landing page), render immediately with null context // Also render immediately if user query failed (e.g., token expired) diff --git a/surfsense_web/components/providers/GlobalLoadingProvider.tsx b/surfsense_web/components/providers/GlobalLoadingProvider.tsx index db66b9a64..08c888954 100644 --- a/surfsense_web/components/providers/GlobalLoadingProvider.tsx +++ b/surfsense_web/components/providers/GlobalLoadingProvider.tsx @@ -3,9 +3,7 @@ import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { AmbientBackground } from "@/app/(home)/login/AmbientBackground"; import { globalLoadingAtom } from "@/atoms/ui/loading.atoms"; -import { Logo } from "@/components/Logo"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; @@ -18,7 +16,7 @@ import { cn } from "@/lib/utils"; */ export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false); - const { isLoading, message, variant } = useAtomValue(globalLoadingAtom); + const { isLoading } = useAtomValue(globalLoadingAtom); useEffect(() => { setMounted(true); @@ -36,35 +34,11 @@ export function GlobalLoadingProvider({ children }: { children: React.ReactNode )} aria-hidden={!isLoading} > - {variant === "login" ? ( -
- -
- -
-
- {/* Spinner is always mounted, animation never resets */} - -
- - {message} - -
-
+
+
+
- ) : ( -
-
-
- {/* Spinner is always mounted, animation never resets */} - -
- - {message} - -
-
- )} +
); diff --git a/surfsense_web/components/providers/PostHogProvider.tsx b/surfsense_web/components/providers/PostHogProvider.tsx index 2fcca1f9d..1216730f3 100644 --- a/surfsense_web/components/providers/PostHogProvider.tsx +++ b/surfsense_web/components/providers/PostHogProvider.tsx @@ -3,6 +3,7 @@ import { PostHogProvider as PHProvider } from "@posthog/react"; import posthog from "posthog-js"; import type { ReactNode } from "react"; +import "../../instrumentation-client"; import { PostHogIdentify } from "./PostHogIdentify"; interface PostHogProviderProps { @@ -10,8 +11,8 @@ interface PostHogProviderProps { } export function PostHogProvider({ children }: PostHogProviderProps) { - // posthog-js is already initialized in instrumentation-client.ts - // We just need to wrap the app with the PostHogProvider for hook access + // posthog-js is initialized by importing instrumentation-client.ts above + // We wrap the app with the PostHogProvider for hook access return ( diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx new file mode 100644 index 000000000..cf4501c23 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Copy, Loader2 } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; +import { getBearerToken } from "@/lib/auth-utils"; + +interface PublicChatFooterProps { + shareToken: string; +} + +export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isCloning, setIsCloning] = useState(false); + const hasAutoCloned = useRef(false); + + const triggerClone = useCallback(async () => { + setIsCloning(true); + + try { + const response = await publicChatApiService.clonePublicChat({ + share_token: shareToken, + }); + + // Redirect to the new chat page (content will be loaded there) + 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"; + toast.error(message); + setIsCloning(false); + } + }, [shareToken, router]); + + // Auto-trigger clone if user just logged in with action=clone + useEffect(() => { + const action = searchParams.get("action"); + const token = getBearerToken(); + + // Only auto-clone once, if authenticated and action=clone is present + if (action === "clone" && token && !hasAutoCloned.current && !isCloning) { + hasAutoCloned.current = true; + triggerClone(); + } + }, [searchParams, isCloning, triggerClone]); + + const handleCopyAndContinue = async () => { + const token = getBearerToken(); + + if (!token) { + // Include action=clone in the returnUrl so it persists after login + const returnUrl = encodeURIComponent(`/public/${shareToken}?action=clone`); + router.push(`/login?returnUrl=${returnUrl}`); + return; + } + + await triggerClone(); + }; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx new file mode 100644 index 000000000..8b21fede1 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Loader2 } from "lucide-react"; +import { Navbar } from "@/components/homepage/navbar"; +import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; +import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; +import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; +import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; +import { usePublicChat } from "@/hooks/use-public-chat"; +import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime"; +import { PublicChatFooter } from "./public-chat-footer"; +import { PublicThread } from "./public-thread"; + +interface PublicChatViewProps { + shareToken: string; +} + +export function PublicChatView({ shareToken }: PublicChatViewProps) { + const { data, isLoading, error } = usePublicChat(shareToken); + const runtime = usePublicChatRuntime({ data }); + + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (error || !data) { + return ( +
+ +
+

Chat not found

+

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

+
+
+ ); + } + + return ( +
+ + + {/* Tool UIs for rendering tool results */} + + + + + +
+ } /> +
+
+
+ ); +} diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx new file mode 100644 index 000000000..e88e5aae7 --- /dev/null +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { + ActionBarPrimitive, + AssistantIf, + MessagePrimitive, + ThreadPrimitive, + useAssistantState, +} from "@assistant-ui/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; + +interface PublicThreadProps { + footer?: ReactNode; +} + +/** + * Read-only thread component for public chat viewing. + * No composer, no edit capabilities - just message display. + */ +export const PublicThread: FC = ({ footer }) => { + return ( + + + + + {/* Spacer to ensure footer doesn't overlap last message */} +
+ + + {footer && ( +
+ {footer} +
+ )} + + ); +}; + +/** + * User avatar component with fallback to initials + */ +interface AuthorMetadata { + displayName: string | null; + avatarUrl: string | null; +} + +const UserAvatar: FC void }> = ({ + displayName, + avatarUrl, + hasError, + onError, +}) => { + const initials = displayName + ? displayName + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + : "U"; + + if (avatarUrl && !hasError) { + return ( + {displayName + ); + } + + return ( +
+ {initials} +
+ ); +}; + +const PublicUserMessage: FC = () => { + const metadata = useAssistantState(({ message }) => message?.metadata); + const author = metadata?.custom?.author as AuthorMetadata | undefined; + + return ( + +
+
+
+ +
+
+ {author && ( +
+ +
+ )} +
+
+ ); +}; + +const UserAvatarWithState: FC = ({ displayName, avatarUrl }) => { + const [hasError, setHasError] = useState(false); + return ( + setHasError(true)} + /> + ); +}; + +const PublicAssistantMessage: FC = () => { + return ( + +
+ +
+ +
+ +
+
+ ); +}; + +const PublicAssistantActionBar: FC = () => { + return ( + + + + message.isCopied}> + + + !message.isCopied}> + + + + + + ); +}; diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index d40024b7c..3ae0755ef 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -20,21 +20,31 @@ const GeneratePodcastArgsSchema = z.object({ }); const GeneratePodcastResultSchema = z.object({ - status: z.enum(["processing", "already_generating", "success", "error"]), - task_id: z.string().nullish(), + // Support both old and new status values for backwards compatibility + status: z.enum([ + "pending", + "generating", + "ready", + "failed", + // Legacy values from old saved chats + "processing", + "already_generating", + "success", + "error", + ]), podcast_id: z.number().nullish(), + task_id: z.string().nullish(), // Legacy field for old saved chats title: z.string().nullish(), transcript_entries: z.number().nullish(), message: z.string().nullish(), error: z.string().nullish(), }); -const TaskStatusResponseSchema = z.object({ - status: z.enum(["processing", "success", "error"]), - podcast_id: z.number().nullish(), - title: z.string().nullish(), +const PodcastStatusResponseSchema = z.object({ + status: z.enum(["pending", "generating", "ready", "failed"]), + id: z.number(), + title: z.string(), transcript_entries: z.number().nullish(), - state: z.string().nullish(), error: z.string().nullish(), }); @@ -52,17 +62,17 @@ const PodcastDetailsSchema = z.object({ */ type GeneratePodcastArgs = z.infer; type GeneratePodcastResult = z.infer; -type TaskStatusResponse = z.infer; +type PodcastStatusResponse = z.infer; type PodcastTranscriptEntry = z.infer; /** - * Parse and validate task status response + * Parse and validate podcast status response */ -function parseTaskStatusResponse(data: unknown): TaskStatusResponse { - const result = TaskStatusResponseSchema.safeParse(data); +function parsePodcastStatusResponse(data: unknown): PodcastStatusResponse | null { + const result = PodcastStatusResponseSchema.safeParse(data); if (!result.success) { - console.warn("Invalid task status response:", result.error.issues); - return { status: "error", error: "Invalid response from server" }; + console.warn("Invalid podcast status response:", result.error.issues); + return null; } return result.data; } @@ -291,44 +301,42 @@ function PodcastPlayer({ } /** - * Polling component that checks task status and shows player when complete + * Polling component that checks podcast status and shows player when ready */ -function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) { - const [taskStatus, setTaskStatus] = useState({ status: "processing" }); +function PodcastStatusPoller({ podcastId, title }: { podcastId: number; title: string }) { + const [podcastStatus, setPodcastStatus] = useState(null); const pollingRef = useRef(null); // Set active podcast state when this component mounts useEffect(() => { - setActivePodcastTaskId(taskId); + setActivePodcastTaskId(String(podcastId)); // Clear when component unmounts return () => { - // Only clear if this task is still the active one clearActivePodcastTaskId(); }; - }, [taskId]); + }, [podcastId]); - // Poll for task status + // Poll for podcast status useEffect(() => { const pollStatus = async () => { try { - const rawResponse = await baseApiService.get( - `/api/v1/podcasts/task/${taskId}/status` - ); - const response = parseTaskStatusResponse(rawResponse); - setTaskStatus(response); + const rawResponse = await baseApiService.get(`/api/v1/podcasts/${podcastId}`); + const response = parsePodcastStatusResponse(rawResponse); + if (response) { + setPodcastStatus(response); - // Stop polling if task is complete or errored - if (response.status !== "processing") { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; + // Stop polling if podcast is ready or failed + if (response.status === "ready" || response.status === "failed") { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + clearActivePodcastTaskId(); } - // Clear the active podcast state when task completes - clearActivePodcastTaskId(); } } catch (err) { - console.error("Error polling task status:", err); + console.error("Error polling podcast status:", err); // Don't stop polling on network errors, continue polling } }; @@ -344,27 +352,31 @@ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) clearInterval(pollingRef.current); } }; - }, [taskId]); + }, [podcastId]); - // Show loading state while processing - if (taskStatus.status === "processing") { + // Show loading state while pending or generating + if ( + !podcastStatus || + podcastStatus.status === "pending" || + podcastStatus.status === "generating" + ) { return ; } // Show error state - if (taskStatus.status === "error") { - return ; + if (podcastStatus.status === "failed") { + return ; } - // Show player when complete - if (taskStatus.status === "success" && taskStatus.podcast_id) { + // Show player when ready + if (podcastStatus.status === "ready") { return ( @@ -423,14 +435,15 @@ export const GeneratePodcastToolUI = makeAssistantToolUI< return ; } - // Error result - if (result.status === "error") { - return ; + // Failed result (new: "failed", legacy: "error") + if (result.status === "failed" || result.status === "error") { + return ; } // Already generating - show simple warning, don't create another poller // The FIRST tool call will display the podcast when ready - if (result.status === "already_generating") { + // (new: "generating", legacy: "already_generating") + if (result.status === "generating" || result.status === "already_generating") { return (
@@ -450,13 +463,13 @@ export const GeneratePodcastToolUI = makeAssistantToolUI< ); } - // Processing - poll for completion - if (result.status === "processing" && result.task_id) { - return ; + // Pending - poll for completion (new: "pending" with podcast_id) + if (result.status === "pending" && result.podcast_id) { + return ; } - // Success with podcast_id (direct result, not via polling) - if (result.status === "success" && result.podcast_id) { + // Ready with podcast_id (new: "ready", legacy: "success") + if ((result.status === "ready" || result.status === "success") && result.podcast_id) { return ( +
+
+ +
+
+

+ This podcast was generated with an older version and cannot be displayed. +

+

+ Please generate a new podcast to listen. +

+
+
+
+ ); + } + // Fallback - missing required data - return ; + return ; }, }); diff --git a/surfsense_web/contracts/types/chat-threads.types.ts b/surfsense_web/contracts/types/chat-threads.types.ts new file mode 100644 index 000000000..e5ca183bd --- /dev/null +++ b/surfsense_web/contracts/types/chat-threads.types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +/** + * Toggle public share + */ +export const togglePublicShareRequest = z.object({ + thread_id: z.number(), + enabled: z.boolean(), +}); + +export const togglePublicShareResponse = z.object({ + enabled: z.boolean(), + public_url: z.string().nullable(), + share_token: z.string().nullable(), +}); + +// Type exports +export type TogglePublicShareRequest = z.infer; +export type TogglePublicShareResponse = z.infer; diff --git a/surfsense_web/contracts/types/public-chat.types.ts b/surfsense_web/contracts/types/public-chat.types.ts new file mode 100644 index 000000000..f7aea5969 --- /dev/null +++ b/surfsense_web/contracts/types/public-chat.types.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +/** + * Author info for public chat + */ +export const publicAuthor = z.object({ + display_name: z.string().nullable(), + avatar_url: z.string().nullable(), +}); + +/** + * Message in a public chat + */ +export const publicChatMessage = z.object({ + role: z.string(), + content: z.unknown(), + author: publicAuthor.nullable(), + created_at: z.string(), +}); + +/** + * Thread info for public chat + */ +export const publicChatThread = z.object({ + title: z.string(), + created_at: z.string(), +}); + +/** + * Get public chat + */ +export const getPublicChatRequest = z.object({ + share_token: z.string(), +}); + +export const getPublicChatResponse = z.object({ + thread: publicChatThread, + messages: z.array(publicChatMessage), +}); + +/** + * Clone public chat (init) + */ +export const clonePublicChatRequest = z.object({ + share_token: z.string(), +}); + +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 +export type PublicAuthor = z.infer; +export type PublicChatMessage = z.infer; +export type PublicChatThread = z.infer; +export type GetPublicChatRequest = z.infer; +export type GetPublicChatResponse = z.infer; +export type ClonePublicChatRequest = z.infer; +export type ClonePublicChatResponse = z.infer; +export type CompleteCloneRequest = z.infer; +export type CompleteCloneResponse = z.infer; diff --git a/surfsense_web/hooks/use-github-stars.ts b/surfsense_web/hooks/use-github-stars.ts index a4d4f80fd..aa2bad1b9 100644 --- a/surfsense_web/hooks/use-github-stars.ts +++ b/surfsense_web/hooks/use-github-stars.ts @@ -25,6 +25,10 @@ export const useGithubStars = () => { setStars(data?.stargazers_count); } catch (err) { + // Ignore abort errors (expected on unmount) + if (err instanceof Error && err.name === "AbortError") { + return; + } if (err instanceof Error) { console.error("Error fetching stars:", err); setError(err.message); @@ -37,7 +41,7 @@ export const useGithubStars = () => { getStars(); return () => { - abortController.abort(); + abortController.abort("Component unmounted"); }; }, []); diff --git a/surfsense_web/hooks/use-global-loading.ts b/surfsense_web/hooks/use-global-loading.ts index baaa1f089..fee8ae18e 100644 --- a/surfsense_web/hooks/use-global-loading.ts +++ b/surfsense_web/hooks/use-global-loading.ts @@ -20,21 +20,18 @@ let pendingHideTimeout: ReturnType | null = null; export function useGlobalLoading() { const [loading, setLoading] = useAtom(globalLoadingAtom); - const show = useCallback( - (message?: string, variant: "login" | "default" = "default") => { - // Cancel any pending hide - new loading request takes over - if (pendingHideTimeout) { - clearTimeout(pendingHideTimeout); - pendingHideTimeout = null; - } + const show = useCallback(() => { + // Cancel any pending hide - new loading request takes over + if (pendingHideTimeout) { + clearTimeout(pendingHideTimeout); + pendingHideTimeout = null; + } - const id = ++loadingIdCounter; - currentLoadingId = id; - setLoading({ isLoading: true, message, variant }); - return id; - }, - [setLoading] - ); + const id = ++loadingIdCounter; + currentLoadingId = id; + setLoading({ isLoading: true }); + return id; + }, [setLoading]); const hide = useCallback( (id?: number) => { @@ -50,7 +47,7 @@ export function useGlobalLoading() { // Double-check we're still the current loading after the delay if (id === undefined || id === currentLoadingId) { currentLoadingId = null; - setLoading({ isLoading: false, message: undefined, variant: "default" }); + setLoading({ isLoading: false }); } pendingHideTimeout = null; }, 50); // Small delay to allow next component to mount and show loading @@ -70,27 +67,21 @@ export function useGlobalLoading() { * transition loading states (e.g., layout → page). * * @param shouldShow - Whether the loading screen should be visible - * @param message - Optional message to display - * @param variant - Visual style variant ("login" or "default") */ -export function useGlobalLoadingEffect( - shouldShow: boolean, - message?: string, - variant: "login" | "default" = "default" -) { +export function useGlobalLoadingEffect(shouldShow: boolean) { const { show, hide } = useGlobalLoading(); const loadingIdRef = useRef(null); useEffect(() => { if (shouldShow) { // Show loading and store the ID - loadingIdRef.current = show(message, variant); + loadingIdRef.current = show(); } else if (loadingIdRef.current !== null) { // Only hide if we were the ones showing loading hide(loadingIdRef.current); loadingIdRef.current = null; } - }, [shouldShow, message, variant, show, hide]); + }, [shouldShow, show, hide]); // Cleanup on unmount - only hide if we're still the active loading useEffect(() => { diff --git a/surfsense_web/hooks/use-public-chat-runtime.ts b/surfsense_web/hooks/use-public-chat-runtime.ts new file mode 100644 index 000000000..2e79e0e1b --- /dev/null +++ b/surfsense_web/hooks/use-public-chat-runtime.ts @@ -0,0 +1,51 @@ +"use client"; + +import { type AppendMessage, useExternalStoreRuntime } from "@assistant-ui/react"; +import { useCallback, useMemo } from "react"; +import type { GetPublicChatResponse, PublicChatMessage } from "@/contracts/types/public-chat.types"; +import { convertToThreadMessage } from "@/lib/chat/message-utils"; +import type { MessageRecord } from "@/lib/chat/thread-persistence"; + +interface UsePublicChatRuntimeOptions { + data: GetPublicChatResponse | undefined; +} + +/** + * Map PublicChatMessage to MessageRecord shape for reuse of convertToThreadMessage + */ +function toMessageRecord(msg: PublicChatMessage, idx: number): MessageRecord { + return { + id: idx, + thread_id: 0, + role: msg.role as "user" | "assistant" | "system", + content: msg.content, + created_at: msg.created_at, + author_id: msg.author ? "public" : null, + author_display_name: msg.author?.display_name ?? null, + author_avatar_url: msg.author?.avatar_url ?? null, + }; +} + +/** + * Creates a read-only runtime for public chat viewing. + */ +export function usePublicChatRuntime({ data }: UsePublicChatRuntimeOptions) { + const messages = useMemo(() => data?.messages ?? [], [data?.messages]); + + // No-op - public chat is read-only + const onNew = useCallback(async (_message: AppendMessage) => {}, []); + + const convertMessage = useCallback( + (msg: PublicChatMessage, idx: number) => convertToThreadMessage(toMessageRecord(msg, idx)), + [] + ); + + const runtime = useExternalStoreRuntime({ + isRunning: false, + messages, + onNew, + convertMessage, + }); + + return runtime; +} diff --git a/surfsense_web/hooks/use-public-chat.ts b/surfsense_web/hooks/use-public-chat.ts new file mode 100644 index 000000000..83f34712e --- /dev/null +++ b/surfsense_web/hooks/use-public-chat.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import type { GetPublicChatResponse } from "@/contracts/types/public-chat.types"; +import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export function usePublicChat(shareToken: string) { + return useQuery({ + queryKey: cacheKeys.publicChat.byToken(shareToken), + queryFn: () => publicChatApiService.getPublicChat({ share_token: shareToken }), + enabled: !!shareToken, + staleTime: 30_000, + retry: false, + }); +} diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index 15f989bb4..e6b346073 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -12,5 +12,17 @@ if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { capture_pageview: "history_change", // Enable session recording capture_pageleave: true, + loaded: (posthog) => { + // Expose PostHog to window for console access and toolbar + if (typeof window !== "undefined") { + window.posthog = posthog; + } + }, }); } + +// Always expose posthog to window for debugging/toolbar access +// This allows testing feature flags even without POSTHOG_KEY configured +if (typeof window !== "undefined") { + window.posthog = posthog; +} diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index dcff4768b..b14818ac1 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -23,7 +23,10 @@ export type RequestOptions = { class BaseApiService { baseUrl: string; - noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; + + // Prefixes that don't require auth (checked with startsWith) + noAuthPrefixes: string[] = ["/api/v1/public/", "/api/v1/podcasts/"]; // Use a getter to always read fresh token from localStorage // This ensures the token is always up-to-date after login/logout @@ -84,7 +87,10 @@ class BaseApiService { } // Validate the bearer token - if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { + const isNoAuthEndpoint = + this.noAuthEndpoints.includes(url) || + this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)); + if (!this.bearerToken && !isNoAuthEndpoint) { throw new AuthenticationError("You are not authenticated. Please login again."); } diff --git a/surfsense_web/lib/apis/chat-threads-api.service.ts b/surfsense_web/lib/apis/chat-threads-api.service.ts new file mode 100644 index 000000000..9ad241c42 --- /dev/null +++ b/surfsense_web/lib/apis/chat-threads-api.service.ts @@ -0,0 +1,33 @@ +import { + type TogglePublicShareRequest, + type TogglePublicShareResponse, + togglePublicShareRequest, + togglePublicShareResponse, +} from "@/contracts/types/chat-threads.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class ChatThreadsApiService { + /** + * Toggle public sharing for a thread. + * Requires authentication. + */ + togglePublicShare = async ( + request: TogglePublicShareRequest + ): Promise => { + const parsed = togglePublicShareRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.patch( + `/api/v1/threads/${parsed.data.thread_id}/public-share`, + togglePublicShareResponse, + { body: { enabled: parsed.data.enabled } } + ); + }; +} + +export const chatThreadsApiService = new ChatThreadsApiService(); diff --git a/surfsense_web/lib/apis/public-chat-api.service.ts b/surfsense_web/lib/apis/public-chat-api.service.ts new file mode 100644 index 000000000..49b1bd686 --- /dev/null +++ b/surfsense_web/lib/apis/public-chat-api.service.ts @@ -0,0 +1,73 @@ +import { + type ClonePublicChatRequest, + type ClonePublicChatResponse, + type CompleteCloneRequest, + type CompleteCloneResponse, + clonePublicChatRequest, + clonePublicChatResponse, + completeCloneRequest, + completeCloneResponse, + type GetPublicChatRequest, + type GetPublicChatResponse, + getPublicChatRequest, + getPublicChatResponse, +} from "@/contracts/types/public-chat.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class PublicChatApiService { + /** + * Get a public chat by share token. + * No authentication required. + */ + getPublicChat = async (request: GetPublicChatRequest): Promise => { + const parsed = getPublicChatRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get(`/api/v1/public/${parsed.data.share_token}`, getPublicChatResponse); + }; + + /** + * Clone a public chat to the user's account. + * Creates an empty thread and returns thread_id for redirect. + * Requires authentication. + */ + clonePublicChat = async (request: ClonePublicChatRequest): Promise => { + const parsed = clonePublicChatRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post( + `/api/v1/public/${parsed.data.share_token}/clone`, + clonePublicChatResponse + ); + }; + + /** + * 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(); diff --git a/surfsense_web/lib/chat/message-utils.ts b/surfsense_web/lib/chat/message-utils.ts new file mode 100644 index 000000000..868ed28eb --- /dev/null +++ b/surfsense_web/lib/chat/message-utils.ts @@ -0,0 +1,109 @@ +import type { ThreadMessageLike } from "@assistant-ui/react"; +import { z } from "zod"; +import type { MessageRecord } from "./thread-persistence"; + +/** + * Zod schema for persisted attachment info + */ +const PersistedAttachmentSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + contentType: z.string().optional(), + imageDataUrl: z.string().optional(), + extractedContent: z.string().optional(), +}); + +const AttachmentsPartSchema = z.object({ + type: z.literal("attachments"), + items: z.array(PersistedAttachmentSchema), +}); + +type PersistedAttachment = z.infer; + +/** + * Extract persisted attachments from message content (type-safe with Zod) + */ +function extractPersistedAttachments(content: unknown): PersistedAttachment[] { + if (!Array.isArray(content)) return []; + + for (const part of content) { + const result = AttachmentsPartSchema.safeParse(part); + if (result.success) { + return result.data.items; + } + } + + return []; +} + +/** + * Convert backend message to assistant-ui ThreadMessageLike format + * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps + * Restores attachments for user messages from persisted data + */ +export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { + let content: ThreadMessageLike["content"]; + + if (typeof msg.content === "string") { + content = [{ type: "text", text: msg.content }]; + } else if (Array.isArray(msg.content)) { + // Filter out custom metadata parts - they're handled separately + const filteredContent = msg.content.filter((part: unknown) => { + if (typeof part !== "object" || part === null || !("type" in part)) return true; + const partType = (part as { type: string }).type; + // Filter out thinking-steps, mentioned-documents, and attachments + return ( + partType !== "thinking-steps" && + partType !== "mentioned-documents" && + partType !== "attachments" + ); + }); + content = + filteredContent.length > 0 + ? (filteredContent as ThreadMessageLike["content"]) + : [{ type: "text", text: "" }]; + } else { + content = [{ type: "text", text: String(msg.content) }]; + } + + // Restore attachments for user messages + let attachments: ThreadMessageLike["attachments"]; + if (msg.role === "user") { + const persistedAttachments = extractPersistedAttachments(msg.content); + if (persistedAttachments.length > 0) { + attachments = persistedAttachments.map((att) => ({ + id: att.id, + name: att.name, + type: att.type as "document" | "image" | "file", + contentType: att.contentType || "application/octet-stream", + status: { type: "complete" as const }, + content: [], + // Custom fields for our ChatAttachment interface + imageDataUrl: att.imageDataUrl, + extractedContent: att.extractedContent, + })); + } + } + + // Build metadata.custom for author display in shared chats + const metadata = msg.author_id + ? { + custom: { + author: { + displayName: msg.author_display_name ?? null, + avatarUrl: msg.author_avatar_url ?? null, + }, + }, + } + : undefined; + + return { + id: `msg-${msg.id}`, + role: msg.role, + content, + createdAt: new Date(msg.created_at), + attachments, + metadata, + }; +} diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index 08c08ba78..540fbdc70 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -24,6 +24,9 @@ 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 { diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 8ffc3b786..e6cf5610b 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -79,4 +79,7 @@ export const cacheKeys = { comments: { byMessage: (messageId: number) => ["comments", "message", messageId] as const, }, + publicChat: { + byToken: (shareToken: string) => ["public-chat", shareToken] as const, + }, }; diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 48f32466b..a9a75d8dc 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -2,8 +2,6 @@ "common": { "app_name": "SurfSense", "welcome": "Welcome", - "loading": "Loading", - "initializing": "Initializing", "save": "Save", "cancel": "Cancel", "delete": "Delete", @@ -80,8 +78,7 @@ "passwords_no_match_desc": "The passwords you entered do not match", "creating_account": "Creating your account", "creating_account_btn": "Creating account", - "redirecting_login": "Redirecting to login page", - "processing_authentication": "Processing authentication" + "redirecting_login": "Redirecting to login page" }, "searchSpace": { "create_title": "Create Search Space", @@ -146,10 +143,7 @@ "api_keys": "API Keys", "profile": "Profile", "loading_dashboard": "Loading Dashboard", - "checking_auth": "Checking authentication", "loading_config": "Loading Configuration", - "checking_llm_prefs": "Checking your LLM preferences", - "setting_up_ai": "Setting up AI", "config_error": "Configuration Error", "failed_load_llm_config": "Failed to load your LLM configuration", "error_loading_chats": "Error loading chats", @@ -171,7 +165,6 @@ "create_search_space": "Create Search Space", "add_new_search_space": "Add New Search Space", "loading": "Loading", - "fetching_spaces": "Fetching your search spaces", "may_take_moment": "This may take a moment", "error": "Error", "something_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 051327668..7c0fd8400 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -2,8 +2,6 @@ "common": { "app_name": "SurfSense", "welcome": "欢迎", - "loading": "加载中...", - "initializing": "正在初始化", "save": "保存", "cancel": "取消", "delete": "删除", @@ -80,8 +78,7 @@ "passwords_no_match_desc": "您输入的密码不一致", "creating_account": "正在创建您的账户", "creating_account_btn": "创建中", - "redirecting_login": "正在跳转到登录页面", - "processing_authentication": "正在处理身份验证" + "redirecting_login": "正在跳转到登录页面" }, "searchSpace": { "create_title": "创建搜索空间", @@ -131,10 +128,7 @@ "api_keys": "API 密钥", "profile": "个人资料", "loading_dashboard": "正在加载仪表盘", - "checking_auth": "正在检查身份验证", "loading_config": "正在加载配置", - "checking_llm_prefs": "正在检查您的 LLM 偏好设置", - "setting_up_ai": "正在设置 AI", "config_error": "配置错误", "failed_load_llm_config": "无法加载您的 LLM 配置", "error_loading_chats": "加载对话失败", @@ -156,7 +150,6 @@ "create_search_space": "创建搜索空间", "add_new_search_space": "添加新的搜索空间", "loading": "加载中", - "fetching_spaces": "正在获取您的搜索空间", "may_take_moment": "这可能需要一些时间", "error": "错误", "something_wrong": "出现错误", diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 17dee6251..9c7d25378 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -86,8 +86,8 @@ "next-themes": "^0.4.6", "pg": "^8.16.3", "postgres": "^3.4.7", - "posthog-js": "^1.335.3", - "posthog-node": "^5.24.2", + "posthog-js": "^1.335.5", + "posthog-node": "^5.24.3", "react": "^19.2.3", "react-day-picker": "^9.8.1", "react-dom": "^19.2.3", diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts new file mode 100644 index 000000000..fcb6878e3 --- /dev/null +++ b/surfsense_web/types/window.d.ts @@ -0,0 +1,9 @@ +import type { PostHog } from "posthog-js"; + +declare global { + interface Window { + posthog?: PostHog; + } +} + +export {};