Merge remote-tracking branch 'upstream/dev' into fix/documents

This commit is contained in:
Anish Sarkar 2026-02-04 03:04:52 +05:30
commit 103baa8b7a
41 changed files with 2054 additions and 475 deletions

View file

@ -239,6 +239,7 @@ ENV POSTGRES_DB=surfsense
ENV DATABASE_URL=postgresql+asyncpg://surfsense:surfsense@localhost:5432/surfsense
ENV CELERY_BROKER_URL=redis://localhost:6379/0
ENV CELERY_RESULT_BACKEND=redis://localhost:6379/0
ENV CELERY_TASK_DEFAULT_QUEUE=surfsense
ENV PYTHONPATH=/app/backend
ENV NEXT_FRONTEND_URL=http://localhost:3000
ENV AUTH_TYPE=LOCAL

View file

@ -53,6 +53,8 @@ services:
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-surfsense}
- CELERY_BROKER_URL=redis://redis:${REDIS_PORT:-6379}/0
- CELERY_RESULT_BACKEND=redis://redis:${REDIS_PORT:-6379}/0
# Queue name isolation - prevents task collision if Redis is shared with other apps
- CELERY_TASK_DEFAULT_QUEUE=surfsense
- PYTHONPATH=/app
- UVICORN_LOOP=asyncio
- UNSTRUCTURED_HAS_PATCHED_LOOP=1

View file

@ -3,6 +3,12 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense
#Celery Config
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# Optional: isolate queues when sharing Redis with other apps
CELERY_TASK_DEFAULT_QUEUE=surfsense
# Redis for app-level features (heartbeats, podcast markers)
# Defaults to CELERY_BROKER_URL when not set
REDIS_APP_URL=redis://localhost:6379/0
#Electric(for migrations only)
ELECTRIC_DB_USER=electric

View file

@ -7,11 +7,14 @@ Create Date: 2026-02-02
Changes:
1. Add created_by_id column (UUID, nullable, foreign key to user.id)
2. Create index on created_by_id for performance
3. Backfill existing documents with search space owner's user_id
3. Backfill existing documents with search space owner's user_id (with progress indicator)
"""
import sys
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
@ -20,11 +23,15 @@ down_revision: str | None = "85"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Batch size for backfill operation
BATCH_SIZE = 5000
def upgrade() -> None:
"""Add created_by_id column to documents and backfill with search space owner."""
# 1. Add created_by_id column (nullable for backward compatibility)
print("Step 1/4: Adding created_by_id column...")
op.execute(
"""
DO $$
@ -39,17 +46,21 @@ def upgrade() -> None:
END$$;
"""
)
print(" Done: created_by_id column added.")
# 2. Create index on created_by_id for efficient queries
print("Step 2/4: Creating index on created_by_id...")
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_documents_created_by_id
ON documents (created_by_id);
"""
)
print(" Done: Index created.")
# 3. Add foreign key constraint with ON DELETE SET NULL
# First check if constraint already exists
print("Step 3/4: Adding foreign key constraint...")
op.execute(
"""
DO $$
@ -67,18 +78,69 @@ def upgrade() -> None:
END$$;
"""
)
print(" Done: Foreign key constraint added.")
# 4. Backfill existing documents with search space owner's user_id
# This ensures all existing documents are associated with the search space owner
op.execute(
"""
UPDATE documents
SET created_by_id = searchspaces.user_id
FROM searchspaces
WHERE documents.search_space_id = searchspaces.id
AND documents.created_by_id IS NULL;
"""
# Process in batches with progress indicator
print("Step 4/4: Backfilling created_by_id for existing documents...")
connection = op.get_bind()
# Get total count of documents that need backfilling
result = connection.execute(
sa.text("""
SELECT COUNT(*) FROM documents WHERE created_by_id IS NULL
""")
)
total_count = result.scalar()
if total_count == 0:
print(" No documents need backfilling. Skipping.")
return
print(f" Total documents to backfill: {total_count:,}")
processed = 0
batch_num = 0
while processed < total_count:
batch_num += 1
# Update a batch of documents using a subquery to limit the update
# We use ctid (tuple identifier) for efficient batching in PostgreSQL
result = connection.execute(
sa.text("""
UPDATE documents
SET created_by_id = searchspaces.user_id
FROM searchspaces
WHERE documents.search_space_id = searchspaces.id
AND documents.created_by_id IS NULL
AND documents.id IN (
SELECT d.id FROM documents d
WHERE d.created_by_id IS NULL
LIMIT :batch_size
)
"""),
{"batch_size": BATCH_SIZE},
)
rows_updated = result.rowcount
if rows_updated == 0:
# No more rows to update
break
processed += rows_updated
progress_pct = min(100.0, (processed / total_count) * 100)
# Print progress with carriage return for in-place update
sys.stdout.write(
f"\r Progress: {processed:,}/{total_count:,} documents ({progress_pct:.1f}%) - Batch {batch_num}"
)
sys.stdout.flush()
# Final newline after progress
print()
print(f" Done: Backfilled {processed:,} documents.")
def downgrade() -> None:

View file

@ -0,0 +1,58 @@
"""Make podcast_transcript nullable
Revision ID: 88
Revises: 87
Create Date: 2026-02-02
The podcast workflow now creates a podcast record with PENDING status first,
then fills in the transcript after generation completes. This requires
podcast_transcript to be nullable.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "88"
down_revision: str | None = "87"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Make podcast_transcript nullable and remove the server default
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN podcast_transcript DROP NOT NULL;
"""
)
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN podcast_transcript DROP DEFAULT;
"""
)
def downgrade() -> None:
# Set empty JSON for any NULL values before adding NOT NULL constraint
op.execute(
"""
UPDATE podcasts
SET podcast_transcript = '{}'::jsonb
WHERE podcast_transcript IS NULL;
"""
)
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN podcast_transcript SET DEFAULT '{}';
"""
)
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN podcast_transcript SET NOT NULL;
"""
)

View file

@ -0,0 +1,46 @@
"""Make podcast file_location nullable
Revision ID: 89
Revises: 88
Create Date: 2026-02-03
The podcast workflow creates a podcast record with PENDING status first,
then fills in the file_location after audio generation completes. This requires
file_location to be nullable.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "89"
down_revision: str | None = "88"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Make file_location nullable
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN file_location DROP NOT NULL;
"""
)
def downgrade() -> None:
# Set empty string for any NULL values before adding NOT NULL constraint
op.execute(
"""
UPDATE podcasts
SET file_location = ''
WHERE file_location IS NULL;
"""
)
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN file_location SET NOT NULL;
"""
)

View file

@ -0,0 +1,66 @@
"""Add public_sharing permissions to existing roles
Revision ID: 90
Revises: 89
Create Date: 2026-02-02
"""
from sqlalchemy import text
from alembic import op
revision = "90"
down_revision = "89"
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'public_sharing:view')
WHERE name IN ('Editor', 'Viewer')
AND NOT ('public_sharing:view' = ANY(permissions))
"""
)
)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'public_sharing:create')
WHERE name = 'Editor'
AND NOT ('public_sharing:create' = ANY(permissions))
"""
)
)
def downgrade():
connection = op.get_bind()
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'public_sharing:view')
WHERE name IN ('Editor', 'Viewer')
"""
)
)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'public_sharing:create')
WHERE name = 'Editor'
"""
)
)

View file

@ -21,8 +21,11 @@ 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")
# Defaults to the Celery broker when REDIS_APP_URL is not set
REDIS_URL = os.getenv(
"REDIS_APP_URL",
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
)
_redis_client: redis.Redis | None = None

View file

@ -26,6 +26,7 @@ def init_worker(**kwargs):
# Get Celery configuration from environment
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "surfsense")
# Get schedule checker interval from environment
# Format: "<number><unit>" where unit is 'm' (minutes) or 'h' (hours)
@ -92,6 +93,9 @@ celery_app.conf.update(
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_default_queue=CELERY_TASK_DEFAULT_QUEUE,
task_default_exchange=CELERY_TASK_DEFAULT_QUEUE,
task_default_routing_key=CELERY_TASK_DEFAULT_QUEUE,
# Task execution settings
task_track_started=True,
task_time_limit=28800, # 8 hour hard limit

View file

@ -122,8 +122,52 @@ global_llm_configs:
use_default_system_instructions: false
citations_enabled: true
# Example: Groq - Fast inference
# Example: Azure OpenAI GPT-4o
# IMPORTANT: For Azure deployments, always include 'base_model' in litellm_params
# to enable accurate token counting, cost tracking, and max token limits
- id: -5
name: "Global Azure GPT-4o"
description: "Azure OpenAI GPT-4o deployment"
provider: "AZURE"
# model_name format for Azure: azure/<your-deployment-name>
model_name: "azure/gpt-4o-deployment"
api_key: "your-azure-api-key-here"
api_base: "https://your-resource.openai.azure.com"
api_version: "2024-02-15-preview" # Azure API version
rpm: 1000
tpm: 150000
litellm_params:
temperature: 0.7
max_tokens: 4000
# REQUIRED for Azure: Specify the underlying OpenAI model
# This fixes "Could not identify azure model" warnings
# Common base_model values: gpt-4, gpt-4-turbo, gpt-4o, gpt-4o-mini, gpt-3.5-turbo
base_model: "gpt-4o"
system_instructions: ""
use_default_system_instructions: true
citations_enabled: true
# Example: Azure OpenAI GPT-4 Turbo
- id: -6
name: "Global Azure GPT-4 Turbo"
description: "Azure OpenAI GPT-4 Turbo deployment"
provider: "AZURE"
model_name: "azure/gpt-4-turbo-deployment"
api_key: "your-azure-api-key-here"
api_base: "https://your-resource.openai.azure.com"
api_version: "2024-02-15-preview"
rpm: 500
tpm: 100000
litellm_params:
temperature: 0.7
max_tokens: 4000
base_model: "gpt-4-turbo" # Maps to gpt-4-turbo-preview
system_instructions: ""
use_default_system_instructions: true
citations_enabled: true
# Example: Groq - Fast inference
- id: -7
name: "Global Groq Llama 3"
description: "Ultra-fast Llama 3 70B via Groq"
provider: "GROQ"
@ -150,3 +194,11 @@ global_llm_configs:
# - All standard LiteLLM providers are supported
# - rpm/tpm: Optional rate limits for load balancing (requests/tokens per minute)
# These help the router distribute load evenly and avoid rate limit errors
#
# AZURE-SPECIFIC NOTES:
# - Always add 'base_model' in litellm_params for Azure deployments
# - This fixes "Could not identify azure model 'X'" warnings
# - base_model should match the underlying OpenAI model (e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo)
# - model_name format: "azure/<your-deployment-name>"
# - api_version: Use a recent Azure API version (e.g., "2024-02-15-preview")
# - See: https://docs.litellm.ai/docs/proxy/cost_tracking#spend-tracking-for-azure-openai-models

View file

@ -257,6 +257,11 @@ class Permission(str, Enum):
SETTINGS_UPDATE = "settings:update"
SETTINGS_DELETE = "settings:delete" # Delete the entire search space
# Public Sharing
PUBLIC_SHARING_VIEW = "public_sharing:view"
PUBLIC_SHARING_CREATE = "public_sharing:create"
PUBLIC_SHARING_DELETE = "public_sharing:delete"
# Full access wildcard
FULL_ACCESS = "*"
@ -299,6 +304,9 @@ DEFAULT_ROLE_PERMISSIONS = {
Permission.ROLES_READ.value,
# Settings (view only, no update or delete)
Permission.SETTINGS_VIEW.value,
# Public Sharing (can create and view, no delete)
Permission.PUBLIC_SHARING_VIEW.value,
Permission.PUBLIC_SHARING_CREATE.value,
],
"Viewer": [
# Documents (read only)
@ -322,6 +330,8 @@ DEFAULT_ROLE_PERMISSIONS = {
Permission.ROLES_READ.value,
# Settings (view only)
Permission.SETTINGS_VIEW.value,
# Public Sharing (view only)
Permission.PUBLIC_SHARING_VIEW.value,
],
}

View file

@ -45,9 +45,9 @@ from app.schemas.new_chat import (
NewChatThreadUpdate,
NewChatThreadVisibilityUpdate,
NewChatThreadWithMessages,
PublicChatSnapshotCreateResponse,
PublicChatSnapshotListResponse,
RegenerateRequest,
SnapshotCreateResponse,
SnapshotListResponse,
ThreadHistoryLoadResponse,
ThreadListItem,
ThreadListResponse,
@ -736,10 +736,11 @@ async def update_thread_visibility(
# =============================================================================
@router.post("/threads/{thread_id}/snapshots", response_model=SnapshotCreateResponse)
@router.post(
"/threads/{thread_id}/snapshots", response_model=PublicChatSnapshotCreateResponse
)
async def create_thread_snapshot(
thread_id: int,
request: Request,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
@ -747,23 +748,21 @@ async def create_thread_snapshot(
Create a public snapshot of the thread.
Returns existing snapshot URL if content unchanged (deduplication).
Only the thread owner can create snapshots.
"""
from app.services.public_chat_service import create_snapshot
base_url = str(request.base_url).rstrip("/")
return await create_snapshot(
session=session,
thread_id=thread_id,
user=user,
base_url=base_url,
)
@router.get("/threads/{thread_id}/snapshots", response_model=SnapshotListResponse)
@router.get(
"/threads/{thread_id}/snapshots", response_model=PublicChatSnapshotListResponse
)
async def list_thread_snapshots(
thread_id: int,
request: Request,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
@ -774,13 +773,11 @@ async def list_thread_snapshots(
"""
from app.services.public_chat_service import list_snapshots_for_thread
base_url = str(request.base_url).rstrip("/")
return SnapshotListResponse(
return PublicChatSnapshotListResponse(
snapshots=await list_snapshots_for_thread(
session=session,
thread_id=thread_id,
user=user,
base_url=base_url,
)
)

View file

@ -91,7 +91,10 @@ def get_heartbeat_redis_client() -> redis.Redis:
"""Get or create Redis client for heartbeat tracking."""
global _heartbeat_redis_client
if _heartbeat_redis_client is None:
redis_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
redis_url = os.getenv(
"REDIS_APP_URL",
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
)
_heartbeat_redis_client = redis.from_url(redis_url, decode_responses=True)
return _heartbeat_redis_client

View file

@ -501,3 +501,25 @@ async def update_llm_preferences(
raise HTTPException(
status_code=500, detail=f"Failed to update LLM preferences: {e!s}"
) from e
@router.get("/searchspaces/{search_space_id}/snapshots")
async def list_search_space_snapshots(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List all public chat snapshots for a search space.
Requires PUBLIC_SHARING_VIEW permission.
"""
from app.schemas.new_chat import PublicChatSnapshotsBySpaceResponse
from app.services.public_chat_service import list_snapshots_for_search_space
snapshots = await list_snapshots_for_search_space(
session=session,
search_space_id=search_space_id,
user=user,
)
return PublicChatSnapshotsBySpaceResponse(snapshots=snapshots)

View file

@ -211,17 +211,17 @@ class RegenerateRequest(BaseModel):
# =============================================================================
class SnapshotCreateResponse(BaseModel):
"""Response after creating a public snapshot."""
class PublicChatSnapshotCreateResponse(BaseModel):
"""Response after creating a public chat snapshot."""
snapshot_id: int
share_token: str
public_url: str
is_new: bool # False if existing snapshot returned (same content)
is_new: bool
class SnapshotInfo(BaseModel):
"""Info about a single snapshot."""
class PublicChatSnapshotInfo(BaseModel):
"""Info about a single public chat snapshot."""
id: int
share_token: str
@ -230,10 +230,28 @@ class SnapshotInfo(BaseModel):
message_count: int
class SnapshotListResponse(BaseModel):
"""List of snapshots for a thread."""
class PublicChatSnapshotListResponse(BaseModel):
"""List of public chat snapshots for a thread."""
snapshots: list[SnapshotInfo]
snapshots: list[PublicChatSnapshotInfo]
class PublicChatSnapshotDetail(BaseModel):
"""Public chat snapshot with thread context."""
id: int
share_token: str
public_url: str
created_at: datetime
message_count: int
thread_id: int
thread_title: str
class PublicChatSnapshotsBySpaceResponse(BaseModel):
"""List of public chat snapshots for a search space."""
snapshots: list[PublicChatSnapshotDetail]
# =============================================================================

View file

@ -25,12 +25,14 @@ from app.db import (
ChatVisibility,
NewChatMessage,
NewChatThread,
Permission,
Podcast,
PodcastStatus,
PublicChatSnapshot,
SearchSpaceMembership,
User,
)
from app.utils.rbac import check_permission
UI_TOOLS = {
"display_image",
@ -159,7 +161,6 @@ async def create_snapshot(
session: AsyncSession,
thread_id: int,
user: User,
base_url: str,
) -> dict:
"""
Create a public snapshot of a chat thread.
@ -167,6 +168,9 @@ async def create_snapshot(
Returns existing snapshot if content unchanged (same hash).
Returns new snapshot with unique URL if content changed.
"""
from app.config import config
frontend_url = (config.NEXT_FRONTEND_URL or "").rstrip("/")
result = await session.execute(
select(NewChatThread)
.options(selectinload(NewChatThread.messages))
@ -177,11 +181,13 @@ async def create_snapshot(
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
if thread.created_by_id != user.id:
raise HTTPException(
status_code=403,
detail="Only the creator of this chat can create public snapshots",
)
await check_permission(
session,
user,
thread.search_space_id,
Permission.PUBLIC_SHARING_CREATE.value,
"You don't have permission to create public share links",
)
# Build snapshot data
user_cache: dict[UUID, dict] = {}
@ -246,7 +252,7 @@ async def create_snapshot(
return {
"snapshot_id": existing.id,
"share_token": existing.share_token,
"public_url": f"{base_url}/public/{existing.share_token}",
"public_url": f"{frontend_url}/public/{existing.share_token}",
"is_new": False,
}
@ -279,7 +285,7 @@ async def create_snapshot(
return {
"snapshot_id": snapshot.id,
"share_token": snapshot.share_token,
"public_url": f"{base_url}/public/{snapshot.share_token}",
"public_url": f"{frontend_url}/public/{snapshot.share_token}",
"is_new": True,
}
@ -348,10 +354,10 @@ async def list_snapshots_for_thread(
session: AsyncSession,
thread_id: int,
user: User,
base_url: str,
) -> list[dict]:
"""List all public snapshots for a thread."""
# Verify ownership
from app.config import config
result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == thread_id)
)
@ -366,7 +372,6 @@ async def list_snapshots_for_thread(
detail="Only the creator can view snapshots",
)
# Get snapshots
result = await session.execute(
select(PublicChatSnapshot)
.filter(PublicChatSnapshot.thread_id == thread_id)
@ -374,11 +379,13 @@ async def list_snapshots_for_thread(
)
snapshots = result.scalars().all()
frontend_url = (config.NEXT_FRONTEND_URL or "").rstrip("/")
return [
{
"id": s.id,
"share_token": s.share_token,
"public_url": f"{base_url}/public/{s.share_token}",
"public_url": f"{frontend_url}/public/{s.share_token}",
"created_at": s.created_at.isoformat() if s.created_at else None,
"message_count": len(s.message_ids) if s.message_ids else 0,
}
@ -386,6 +393,54 @@ async def list_snapshots_for_thread(
]
async def list_snapshots_for_search_space(
session: AsyncSession,
search_space_id: int,
user: User,
) -> list[dict]:
"""List all public snapshots for a search space."""
from app.config import config
await check_permission(
session,
user,
search_space_id,
Permission.PUBLIC_SHARING_VIEW.value,
"You don't have permission to view public share links",
)
result = await session.execute(
select(PublicChatSnapshot)
.join(NewChatThread, PublicChatSnapshot.thread_id == NewChatThread.id)
.filter(NewChatThread.search_space_id == search_space_id)
.order_by(PublicChatSnapshot.created_at.desc())
)
snapshots = result.scalars().all()
snapshot_thread_ids = [s.thread_id for s in snapshots]
thread_result = await session.execute(
select(NewChatThread.id, NewChatThread.title).filter(
NewChatThread.id.in_(snapshot_thread_ids)
)
)
thread_titles = {row[0]: row[1] for row in thread_result.fetchall()}
frontend_url = (config.NEXT_FRONTEND_URL or "").rstrip("/")
return [
{
"id": s.id,
"share_token": s.share_token,
"public_url": f"{frontend_url}/public/{s.share_token}",
"created_at": s.created_at.isoformat() if s.created_at else None,
"message_count": len(s.message_ids) if s.message_ids else 0,
"thread_id": s.thread_id,
"thread_title": thread_titles.get(s.thread_id, "Untitled"),
}
for s in snapshots
]
# =============================================================================
# Snapshot Deletion
# =============================================================================
@ -412,11 +467,13 @@ async def delete_snapshot(
if not snapshot:
raise HTTPException(status_code=404, detail="Snapshot not found")
if snapshot.thread.created_by_id != user.id:
raise HTTPException(
status_code=403,
detail="Only the creator can delete snapshots",
)
await check_permission(
session,
user,
snapshot.thread.search_space_id,
Permission.PUBLIC_SHARING_DELETE.value,
"You don't have permission to delete public share links",
)
await session.delete(snapshot)
await session.commit()

View file

@ -323,6 +323,28 @@ def process_file_upload_task(
user_id: ID of the user
"""
import asyncio
import os
import traceback
logger.info(
f"[process_file_upload] Task started - file: {filename}, "
f"search_space_id: {search_space_id}, user_id: {user_id}"
)
logger.info(f"[process_file_upload] File path: {file_path}")
# Check if file exists and is accessible
if not os.path.exists(file_path):
logger.error(
f"[process_file_upload] File does not exist: {file_path}. "
"The temp file may have been cleaned up before the task ran."
)
return
try:
file_size = os.path.getsize(file_path)
logger.info(f"[process_file_upload] File size: {file_size} bytes")
except Exception as e:
logger.warning(f"[process_file_upload] Could not get file size: {e}")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@ -331,6 +353,15 @@ def process_file_upload_task(
loop.run_until_complete(
_process_file_upload(file_path, filename, search_space_id, user_id)
)
logger.info(
f"[process_file_upload] Task completed successfully for: {filename}"
)
except Exception as e:
logger.error(
f"[process_file_upload] Task failed for {filename}: {e}\n"
f"Traceback:\n{traceback.format_exc()}"
)
raise
finally:
loop.close()
@ -343,16 +374,22 @@ async def _process_file_upload(
from app.tasks.document_processors.file_processors import process_file_in_background
logger.info(f"[_process_file_upload] Starting async processing for: {filename}")
async with get_celery_session_maker()() as session:
logger.info(f"[_process_file_upload] Database session created for: {filename}")
task_logger = TaskLoggingService(session, search_space_id)
# Get file size for notification metadata
try:
file_size = os.path.getsize(file_path)
except Exception:
logger.info(f"[_process_file_upload] File size: {file_size} bytes")
except Exception as e:
logger.warning(f"[_process_file_upload] Could not get file size: {e}")
file_size = None
# Create notification for document processing
logger.info(f"[_process_file_upload] Creating notification for: {filename}")
notification = (
await NotificationService.document_processing.notify_processing_started(
session=session,
@ -363,6 +400,9 @@ async def _process_file_upload(
file_size=file_size,
)
)
logger.info(
f"[_process_file_upload] Notification created with ID: {notification.id if notification else 'None'}"
)
log_entry = await task_logger.log_task_start(
task_name="process_file_upload",

View file

@ -51,7 +51,10 @@ def _clear_generating_podcast(search_space_id: int) -> None:
import redis
try:
redis_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
redis_url = os.getenv(
"REDIS_APP_URL",
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
)
client = redis.from_url(redis_url, decode_responses=True)
key = f"podcast:generating:{search_space_id}"
client.delete(key)

View file

@ -36,7 +36,10 @@ def get_redis_client() -> redis.Redis:
"""Get or create Redis client for heartbeat checking."""
global _redis_client
if _redis_client is None:
redis_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
redis_url = os.getenv(
"REDIS_APP_URL",
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
)
_redis_client = redis.from_url(redis_url, decode_responses=True)
return _redis_client

View file

@ -32,8 +32,6 @@ dependencies = [
"en-core-web-sm@https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
"static-ffmpeg>=2.13",
"tavily-python>=0.3.2",
"unstructured-client>=0.30.0",
"unstructured[all-docs]>=0.16.25",
"uvicorn[standard]>=0.34.0",
"validators>=0.34.0",
"youtube-transcript-api>=1.0.3",
@ -45,7 +43,6 @@ dependencies = [
"firecrawl-py>=4.9.0",
"boto3>=1.35.0",
"langchain-community>=0.3.31",
"langchain-unstructured>=1.0.0",
"litellm>=1.80.10",
"langchain-litellm>=0.3.5",
"fake-useragent>=2.2.0",
@ -62,6 +59,9 @@ dependencies = [
"deepagents>=0.3.8",
"langchain>=1.2.6",
"langgraph>=1.0.5",
"unstructured[all-docs]>=0.18.31",
"unstructured-client>=0.42.3",
"langchain-unstructured>=1.0.1",
]
[dependency-groups]

View file

@ -39,7 +39,7 @@ backend_pid=$!
sleep 5
echo "Starting Celery Worker..."
celery -A app.celery_app worker --loglevel=info &
celery -A app.celery_app worker --loglevel=info --autoscale=128,4 &
celery_worker_pid=$!
# Wait a bit for worker to initialize

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import {
Brain,
ChevronRight,
FileText,
Globe,
type LucideIcon,
Menu,
MessageSquare,
@ -16,6 +17,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
@ -56,6 +58,12 @@ const settingsNavItems: SettingsNavItem[] = [
descriptionKey: "nav_system_instructions_desc",
icon: MessageSquare,
},
{
id: "public-links",
labelKey: "nav_public_links",
descriptionKey: "nav_public_links_desc",
icon: Globe,
},
];
function SettingsSidebar({
@ -276,6 +284,9 @@ function SettingsContent({
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "public-links" && (
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
)}
</motion.div>
</AnimatePresence>
</div>

View file

@ -11,6 +11,7 @@ import {
Crown,
Edit2,
FileText,
Globe,
Hash,
Link2,
LinkIcon,
@ -206,7 +207,15 @@ export default function TeamManagementPage() {
);
const handleUpdateRole = useCallback(
async (roleId: number, data: { permissions?: string[] }): Promise<Role> => {
async (
roleId: number,
data: {
name?: string;
description?: string | null;
permissions?: string[];
is_default?: boolean;
}
): Promise<Role> => {
const request: UpdateRoleRequest = {
search_space_id: searchSpaceId,
role_id: roleId,
@ -827,6 +836,12 @@ const CATEGORY_CONFIG: Record<
description: "Manage search space settings",
order: 10,
},
public_sharing: {
label: "Public Chat Sharing",
icon: Globe,
description: "Share chats publicly via links",
order: 11,
},
};
const ACTION_LABELS: Record<string, string> = {
@ -944,7 +959,7 @@ function RolesTab({
roles,
groupedPermissions,
loading,
onUpdateRole: _onUpdateRole,
onUpdateRole,
onDeleteRole,
onCreateRole,
canUpdate,
@ -954,7 +969,15 @@ function RolesTab({
roles: Role[];
groupedPermissions: Record<string, PermissionWithDescription[]>;
loading: boolean;
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
onUpdateRole: (
roleId: number,
data: {
name?: string;
description?: string | null;
permissions?: string[];
is_default?: boolean;
}
) => Promise<Role>;
onDeleteRole: (roleId: number) => Promise<boolean>;
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
canUpdate: boolean;
@ -962,6 +985,7 @@ function RolesTab({
canCreate: boolean;
}) {
const [showCreateRole, setShowCreateRole] = useState(false);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
if (loading) {
return (
@ -997,6 +1021,21 @@ function RolesTab({
/>
)}
{/* Edit Role Form */}
{editingRoleId !== null &&
(() => {
const roleToEdit = roles.find((r) => r.id === editingRoleId);
if (!roleToEdit) return null;
return (
<EditRoleSection
role={roleToEdit}
groupedPermissions={groupedPermissions}
onUpdateRole={onUpdateRole}
onCancel={() => setEditingRoleId(null)}
/>
);
})()}
{/* Roles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roles.map((role, index) => (
@ -1055,13 +1094,9 @@ function RolesTab({
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
{canUpdate && (
<DropdownMenuItem
onClick={() => {
// TODO: Implement edit role dialog/modal
}}
>
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Edit Role
</DropdownMenuItem>
@ -2026,3 +2061,371 @@ function CreateRoleSection({
</motion.div>
);
}
function EditRoleSection({
role,
groupedPermissions,
onUpdateRole,
onCancel,
}: {
role: Role;
groupedPermissions: Record<string, PermissionWithDescription[]>;
onUpdateRole: (
roleId: number,
data: {
name?: string;
description?: string | null;
permissions?: string[];
is_default?: boolean;
}
) => Promise<Role>;
onCancel: () => void;
}) {
const [saving, setSaving] = useState(false);
const [name, setName] = useState(role.name);
const [description, setDescription] = useState(role.description || "");
const [selectedPermissions, setSelectedPermissions] = useState<string[]>(role.permissions);
const [isDefault, setIsDefault] = useState(role.is_default);
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
// Sort categories by order
const sortedCategories = useMemo(() => {
return Object.keys(groupedPermissions).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
}, [groupedPermissions]);
const handleSave = async () => {
if (!name.trim()) {
toast.error("Please enter a role name");
return;
}
setSaving(true);
try {
await onUpdateRole(role.id, {
name: name.trim(),
description: description.trim() || null,
permissions: selectedPermissions,
is_default: isDefault,
});
toast.success("Role updated successfully");
onCancel();
} catch (error) {
console.error("Failed to update role:", error);
toast.error("Failed to update role");
} finally {
setSaving(false);
}
};
const togglePermission = useCallback((perm: string) => {
setSelectedPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
);
}, []);
const toggleCategory = useCallback(
(category: string) => {
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
if (allSelected) {
setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p)));
} else {
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
}
},
[groupedPermissions, selectedPermissions]
);
const toggleCategoryExpanded = useCallback((category: string) => {
setExpandedCategories((prev) =>
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
);
}, []);
const getCategoryStats = useCallback(
(category: string) => {
const perms = groupedPermissions[category] || [];
const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length;
return { selected, total: perms.length, allSelected: selected === perms.length };
},
[groupedPermissions, selectedPermissions]
);
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6"
>
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Edit2 className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">Edit Role</CardTitle>
<CardDescription className="text-sm">
Modify permissions for "{role.name}"
</CardDescription>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onCancel}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Role Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-role-name">Role Name *</Label>
<Input
id="edit-role-name"
placeholder="e.g., Content Manager"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-role-description">Description</Label>
<Input
id="edit-role-description"
placeholder="Brief description of this role"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{/* Default Role Checkbox */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Checkbox
id="edit-is-default"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked === true)}
/>
<div className="flex-1">
<Label htmlFor="edit-is-default" className="cursor-pointer font-medium">
Set as default role
</Label>
<p className="text-xs text-muted-foreground">
New members without a specific role will be assigned this role
</p>
</div>
</div>
{/* Permissions Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Permissions ({selectedPermissions.length} selected)
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-7"
onClick={() =>
setExpandedCategories(
expandedCategories.length === sortedCategories.length ? [] : sortedCategories
)
}
>
{expandedCategories.length === sortedCategories.length
? "Collapse All"
: "Expand All"}
</Button>
</div>
<div className="space-y-2">
{sortedCategories.map((category) => {
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
description: "",
order: 99,
};
const IconComponent = config.icon;
const stats = getCategoryStats(category);
const isExpanded = expandedCategories.includes(category);
const perms = groupedPermissions[category] || [];
return (
<div key={category} className="rounded-lg border bg-card overflow-hidden">
{/* Category Header */}
<div
className={cn(
"flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors",
stats.allSelected && "bg-primary/5"
)}
onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
tabIndex={0}
role="button"
>
<div className="flex items-center gap-3">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
stats.selected > 0 ? "bg-primary/10" : "bg-muted"
)}
>
<IconComponent
className={cn(
"h-4 w-4",
stats.selected > 0 ? "text-primary" : "text-muted-foreground"
)}
/>
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{config.label}</span>
<Badge
variant={stats.selected > 0 ? "default" : "secondary"}
className="text-xs h-5"
>
{stats.selected}/{stats.total}
</Badge>
</div>
<p className="text-xs text-muted-foreground hidden md:block">
{config.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={stats.allSelected}
onCheckedChange={() => toggleCategory(category)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`}
/>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<svg
className="h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</motion.div>
</div>
</div>
{/* Permissions List */}
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t"
>
<div className="p-3 space-y-1">
{perms.map((perm) => {
const action = perm.value.split(":")[1];
const actionConfig = ACTION_DISPLAY[action] || {
label: action,
color: "text-gray-600 bg-gray-500/10",
};
const isSelected = selectedPermissions.includes(perm.value);
return (
<div
key={perm.value}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: "hover:bg-muted/50"
)}
onClick={() => togglePermission(perm.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.value);
}
}}
tabIndex={0}
role="checkbox"
aria-checked={isSelected}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePermission(perm.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded",
actionConfig.color
)}
>
{actionConfig.label}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{perm.description}
</p>
</div>
</div>
</div>
);
})}
</div>
</motion.div>
)}
</div>
);
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View file

@ -1,31 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateSnapshotRequest,
CreateSnapshotResponse,
} from "@/contracts/types/chat-threads.types";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
export const createSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: CreateSnapshotRequest) => {
return chatThreadsApiService.createSnapshot(request);
},
onSuccess: (response: CreateSnapshotResponse) => {
// Construct URL using frontend origin (backend returns its own URL which differs)
const publicUrl = `${window.location.origin}/public/${response.share_token}`;
navigator.clipboard.writeText(publicUrl);
if (response.is_new) {
toast.success("Public link created and copied to clipboard", {
description: "Anyone with this link can view a snapshot of this chat",
});
} else {
toast.success("Public link copied to clipboard", {
description: "This snapshot already exists",
});
}
},
onError: (error: Error) => {
console.error("Failed to create snapshot:", error);
toast.error("Failed to create public link");
},
}));

View file

@ -0,0 +1,53 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
PublicChatSnapshotCreateRequest,
PublicChatSnapshotCreateResponse,
PublicChatSnapshotDeleteRequest,
} from "@/contracts/types/chat-threads.types";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: PublicChatSnapshotCreateRequest) => {
return chatThreadsApiService.createPublicChatSnapshot(request);
},
onSuccess: (response: PublicChatSnapshotCreateResponse) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.publicChatSnapshots.all,
});
const publicUrl = `${window.location.origin}/public/${response.share_token}`;
navigator.clipboard.writeText(publicUrl);
if (response.is_new) {
toast.success("Public link created and copied to clipboard", {
description: "Anyone with this link can view a snapshot of this chat",
});
} else {
toast.success("Public link copied to clipboard", {
description: "This snapshot already exists",
});
}
},
onError: (error: Error) => {
console.error("Failed to create snapshot:", error);
toast.error("Failed to create public link");
},
}));
export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: PublicChatSnapshotDeleteRequest) => {
return chatThreadsApiService.deletePublicChatSnapshot(request);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.publicChatSnapshots.all,
});
toast.success("Public link deleted");
},
onError: (error: Error) => {
console.error("Failed to delete public chat link:", error);
toast.error("Failed to delete public link");
},
}));

View file

@ -0,0 +1,22 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const publicChatSnapshotsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.publicChatSnapshots.bySearchSpace(Number(searchSpaceId) || 0),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!searchSpaceId) {
return { snapshots: [] };
}
return chatThreadsApiService.listPublicChatSnapshotsForSearchSpace({
search_space_id: Number(searchSpaceId),
});
},
};
});

View file

@ -536,10 +536,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
role="textbox"
aria-multiline="true"
/>
{/* Placeholder */}
{/* Placeholder with fade animation on change */}
{isEmpty && (
<div
className="absolute top-0 left-0 pointer-events-none text-muted-foreground text-sm"
key={placeholder}
className="absolute top-0 left-0 pointer-events-none text-muted-foreground text-sm animate-in fade-in duration-1000"
aria-hidden="true"
>
{placeholder}

View file

@ -65,6 +65,16 @@ import type { Document } from "@/contracts/types/document.types";
import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */
const CYCLING_PLACEHOLDERS = [
"Ask SurfSense anything or @mention docs.",
"Generate a podcast from marketing tips in the company handbook.",
"Sum up our vacation policy from Drive.",
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
"Create a concise table of today's top ten emails and calendar events.",
"Check if this week's Slack messages reference any GitHub issues.",
];
interface ThreadProps {
messageThinkingSteps?: Map<string, ThinkingStep[]>;
header?: React.ReactNode;
@ -228,6 +238,30 @@ const Composer: FC = () => {
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Cycling placeholder state - only cycles in new chats
const [placeholderIndex, setPlaceholderIndex] = useState(0);
// Cycle through placeholders every 4 seconds when thread is empty (new chat)
useEffect(() => {
// Only cycle when thread is empty (new chat)
if (!isThreadEmpty) {
// Reset to first placeholder when chat becomes active
setPlaceholderIndex(0);
return;
}
const intervalId = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length);
}, 6000);
return () => clearInterval(intervalId);
}, [isThreadEmpty]);
// Compute current placeholder - only cycle in new chats
const currentPlaceholder = isThreadEmpty
? CYCLING_PLACEHOLDERS[placeholderIndex]
: CYCLING_PLACEHOLDERS[0];
// Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: members } = useAtomValue(membersAtom);
@ -410,7 +444,7 @@ const Composer: FC = () => {
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
placeholder="Ask SurfSense or @mention docs"
placeholder={currentPlaceholder}
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onChange={handleEditorChange}

View file

@ -3,10 +3,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Globe, User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -53,9 +54,17 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Snapshot creation mutation
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
createSnapshotMutationAtom
createPublicChatSnapshotMutationAtom
);
// Permission check for public sharing
const { data: access } = useAtomValue(myAccessAtom);
const canCreatePublicLink = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("public_sharing:create") ?? false;
}, [access]);
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
@ -183,35 +192,39 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
);
})}
{/* Divider */}
<div className="border-t border-border my-1" />
{canCreatePublicLink && (
<>
{/* Divider */}
<div className="border-t border-border my-1" />
{/* Public Link Option */}
<button
type="button"
onClick={handleCreatePublicLink}
disabled={isCreatingSnapshot}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
<Globe className="size-4 block text-muted-foreground" />
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Creates a shareable snapshot of this chat
</p>
</div>
</button>
{/* Public Link Option */}
<button
type="button"
onClick={handleCreatePublicLink}
disabled={isCreatingSnapshot}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
<Globe className="size-4 block text-muted-foreground" />
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Creates a shareable snapshot of this chat
</p>
</div>
</button>
</>
)}
</div>
</PopoverContent>
</Popover>

View file

@ -0,0 +1,67 @@
"use client";
import { Copy, MessageSquare, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
interface PublicChatSnapshotRowProps {
snapshot: PublicChatSnapshotDetail;
canDelete: boolean;
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
isDeleting?: boolean;
}
export function PublicChatSnapshotRow({
snapshot,
canDelete,
onCopy,
onDelete,
isDeleting = false,
}: PublicChatSnapshotRowProps) {
const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<div className="flex items-center justify-between py-3 px-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
<div className="flex-1 min-w-0 mr-4">
<h4 className="text-sm font-medium truncate" title={snapshot.thread_title}>
{snapshot.thread_title}
</h4>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>{formattedDate}</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{snapshot.message_count}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onCopy(snapshot)}
className="h-8 px-2"
title="Copy link"
>
<Copy className="h-4 w-4" />
</Button>
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(snapshot)}
disabled={isDeleting}
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
title="Delete link"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { Link2Off } from "lucide-react";
interface PublicChatSnapshotsEmptyStateProps {
title?: string;
description?: string;
}
export function PublicChatSnapshotsEmptyState({
title = "No public chat links",
description = "When you create public links to share chats, they will appear here.",
}: PublicChatSnapshotsEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-3 mb-4">
<Link2Off className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">{title}</h3>
<p className="text-xs text-muted-foreground max-w-sm">{description}</p>
</div>
);
}

View file

@ -0,0 +1,40 @@
"use client";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
import { PublicChatSnapshotRow } from "./public-chat-snapshot-row";
import { PublicChatSnapshotsEmptyState } from "./public-chat-snapshots-empty-state";
interface PublicChatSnapshotsListProps {
snapshots: PublicChatSnapshotDetail[];
canDelete: boolean;
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
deletingId?: number;
}
export function PublicChatSnapshotsList({
snapshots,
canDelete,
onCopy,
onDelete,
deletingId,
}: PublicChatSnapshotsListProps) {
if (snapshots.length === 0) {
return <PublicChatSnapshotsEmptyState />;
}
return (
<div className="border rounded-md divide-y">
{snapshots.map((snapshot) => (
<PublicChatSnapshotRow
key={snapshot.id}
snapshot={snapshot}
canDelete={canDelete}
onCopy={onCopy}
onDelete={onDelete}
isDeleting={deletingId === snapshot.id}
/>
))}
</div>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Globe, Info } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
import { publicChatSnapshotsAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
import { PublicChatSnapshotsList } from "./public-chat-snapshots-list";
interface PublicChatSnapshotsManagerProps {
searchSpaceId: number;
}
export function PublicChatSnapshotsManager({
searchSpaceId: _searchSpaceId,
}: PublicChatSnapshotsManagerProps) {
const [deletingId, setDeletingId] = useState<number | undefined>();
// Data fetching
const { data: snapshotsData, isLoading, isError } = useAtomValue(publicChatSnapshotsAtom);
// Permissions
const { data: access } = useAtomValue(myAccessAtom);
const canView = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("public_sharing:view") ?? false;
}, [access]);
const canDelete = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("public_sharing:delete") ?? false;
}, [access]);
// Mutations
const { mutateAsync: deleteSnapshot } = useAtomValue(deletePublicChatSnapshotMutationAtom);
// Handlers
const handleCopy = useCallback((snapshot: PublicChatSnapshotDetail) => {
const publicUrl = `${window.location.origin}/public/${snapshot.share_token}`;
navigator.clipboard.writeText(publicUrl);
toast.success("Link copied to clipboard");
}, []);
const handleDelete = useCallback(
async (snapshot: PublicChatSnapshotDetail) => {
try {
setDeletingId(snapshot.id);
await deleteSnapshot({
thread_id: snapshot.thread_id,
snapshot_id: snapshot.id,
});
} catch (error) {
console.error("Failed to delete snapshot:", error);
} finally {
setDeletingId(undefined);
}
},
[deleteSnapshot]
);
// Loading state
if (isLoading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
);
}
// Error state
if (isError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load public chat links. Please try again later.
</AlertDescription>
</Alert>
);
}
// Permission denied
if (!canView) {
return (
<Alert variant="destructive">
<Info className="h-4 w-4" />
<AlertDescription>
You don't have permission to view public chat links in this search space.
</AlertDescription>
</Alert>
);
}
const snapshots = snapshotsData?.snapshots ?? [];
return (
<div className="space-y-4 md:space-y-6">
<Alert className="py-3 md:py-4">
<Globe className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
Public chat links allow anyone with the URL to view a snapshot of a chat. These links do
not update when the original chat changes.
</AlertDescription>
</Alert>
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg flex items-center gap-2">
<Globe className="h-4 w-4 md:h-5 md:w-5" />
Public Chat Links
</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage public links to chats in this search space.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<PublicChatSnapshotsList
snapshots={snapshots}
canDelete={canDelete}
onCopy={handleCopy}
onDelete={handleDelete}
deletingId={deletingId}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,30 @@
"use client";
import { Link2Off } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Navbar } from "@/components/homepage/navbar";
export function PublicChatNotFound() {
const t = useTranslations("public_chat");
return (
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar />
<div className="flex h-screen flex-col items-center justify-center gap-6 px-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Link2Off className="h-8 w-8 text-muted-foreground" />
</div>
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold">{t("not_found_title")}</h1>
<p className="text-muted-foreground">
<Link href="/login" className="text-primary underline hover:text-primary/80">
{t("click_here")}
</Link>{" "}
{t("sign_in_prompt")}
</p>
</div>
</div>
</main>
);
}

View file

@ -10,6 +10,7 @@ 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 { PublicChatNotFound } from "./public-chat-not-found";
import { PublicThread } from "./public-thread";
interface PublicChatViewProps {
@ -32,17 +33,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
}
if (error || !data) {
return (
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar />
<div className="flex h-screen flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-2xl font-semibold">Chat not found</h1>
<p className="text-muted-foreground">
This chat may have been removed or is no longer public.
</p>
</div>
</main>
);
return <PublicChatNotFound />;
}
return (

View file

@ -1,9 +1,9 @@
import { z } from "zod";
/**
* Snapshot info
* Public chat snapshot info
*/
export const snapshotInfo = z.object({
export const publicChatSnapshotInfo = z.object({
id: z.number(),
share_token: z.string(),
public_url: z.string(),
@ -12,13 +12,13 @@ export const snapshotInfo = z.object({
});
/**
* Create snapshot
* Create public chat snapshot
*/
export const createSnapshotRequest = z.object({
export const publicChatSnapshotCreateRequest = z.object({
thread_id: z.number(),
});
export const createSnapshotResponse = z.object({
export const publicChatSnapshotCreateResponse = z.object({
snapshot_id: z.number(),
share_token: z.string(),
public_url: z.string(),
@ -26,28 +26,55 @@ export const createSnapshotResponse = z.object({
});
/**
* List snapshots
* List public chat snapshots for thread
*/
export const listSnapshotsRequest = z.object({
export const publicChatSnapshotListRequest = z.object({
thread_id: z.number(),
});
export const listSnapshotsResponse = z.object({
snapshots: z.array(snapshotInfo),
export const publicChatSnapshotListResponse = z.object({
snapshots: z.array(publicChatSnapshotInfo),
});
/**
* Delete snapshot
* Delete public chat snapshot
*/
export const deleteSnapshotRequest = z.object({
export const publicChatSnapshotDeleteRequest = z.object({
thread_id: z.number(),
snapshot_id: z.number(),
});
/**
* Public chat snapshot with thread context
*/
export const publicChatSnapshotDetail = z.object({
id: z.number(),
share_token: z.string(),
public_url: z.string(),
created_at: z.string(),
message_count: z.number(),
thread_id: z.number(),
thread_title: z.string(),
});
/**
* List public chat snapshots for search space
*/
export const publicChatSnapshotsBySpaceRequest = z.object({
search_space_id: z.number(),
});
export const publicChatSnapshotsBySpaceResponse = z.object({
snapshots: z.array(publicChatSnapshotDetail),
});
// Type exports
export type SnapshotInfo = z.infer<typeof snapshotInfo>;
export type CreateSnapshotRequest = z.infer<typeof createSnapshotRequest>;
export type CreateSnapshotResponse = z.infer<typeof createSnapshotResponse>;
export type ListSnapshotsRequest = z.infer<typeof listSnapshotsRequest>;
export type ListSnapshotsResponse = z.infer<typeof listSnapshotsResponse>;
export type DeleteSnapshotRequest = z.infer<typeof deleteSnapshotRequest>;
export type PublicChatSnapshotInfo = z.infer<typeof publicChatSnapshotInfo>;
export type PublicChatSnapshotCreateRequest = z.infer<typeof publicChatSnapshotCreateRequest>;
export type PublicChatSnapshotCreateResponse = z.infer<typeof publicChatSnapshotCreateResponse>;
export type PublicChatSnapshotListRequest = z.infer<typeof publicChatSnapshotListRequest>;
export type PublicChatSnapshotListResponse = z.infer<typeof publicChatSnapshotListResponse>;
export type PublicChatSnapshotDeleteRequest = z.infer<typeof publicChatSnapshotDeleteRequest>;
export type PublicChatSnapshotDetail = z.infer<typeof publicChatSnapshotDetail>;
export type PublicChatSnapshotsBySpaceRequest = z.infer<typeof publicChatSnapshotsBySpaceRequest>;
export type PublicChatSnapshotsBySpaceResponse = z.infer<typeof publicChatSnapshotsBySpaceResponse>;

View file

@ -1,24 +1,30 @@
import {
type CreateSnapshotRequest,
type CreateSnapshotResponse,
createSnapshotRequest,
createSnapshotResponse,
type DeleteSnapshotRequest,
deleteSnapshotRequest,
type ListSnapshotsRequest,
type ListSnapshotsResponse,
listSnapshotsRequest,
listSnapshotsResponse,
type PublicChatSnapshotCreateRequest,
type PublicChatSnapshotCreateResponse,
type PublicChatSnapshotDeleteRequest,
type PublicChatSnapshotListRequest,
type PublicChatSnapshotListResponse,
type PublicChatSnapshotsBySpaceRequest,
type PublicChatSnapshotsBySpaceResponse,
publicChatSnapshotCreateRequest,
publicChatSnapshotCreateResponse,
publicChatSnapshotDeleteRequest,
publicChatSnapshotListRequest,
publicChatSnapshotListResponse,
publicChatSnapshotsBySpaceRequest,
publicChatSnapshotsBySpaceResponse,
} from "@/contracts/types/chat-threads.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class ChatThreadsApiService {
/**
* Create a public snapshot for a thread.
* Create a public chat snapshot for a thread.
*/
createSnapshot = async (request: CreateSnapshotRequest): Promise<CreateSnapshotResponse> => {
const parsed = createSnapshotRequest.safeParse(request);
createPublicChatSnapshot = async (
request: PublicChatSnapshotCreateRequest
): Promise<PublicChatSnapshotCreateResponse> => {
const parsed = publicChatSnapshotCreateRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
@ -27,15 +33,17 @@ class ChatThreadsApiService {
return baseApiService.post(
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
createSnapshotResponse
publicChatSnapshotCreateResponse
);
};
/**
* List all snapshots for a thread.
* List all public chat snapshots for a thread.
*/
listSnapshots = async (request: ListSnapshotsRequest): Promise<ListSnapshotsResponse> => {
const parsed = listSnapshotsRequest.safeParse(request);
listPublicChatSnapshots = async (
request: PublicChatSnapshotListRequest
): Promise<PublicChatSnapshotListResponse> => {
const parsed = publicChatSnapshotListRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
@ -44,15 +52,15 @@ class ChatThreadsApiService {
return baseApiService.get(
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
listSnapshotsResponse
publicChatSnapshotListResponse
);
};
/**
* Delete a specific snapshot.
* Delete a public chat snapshot.
*/
deleteSnapshot = async (request: DeleteSnapshotRequest): Promise<void> => {
const parsed = deleteSnapshotRequest.safeParse(request);
deletePublicChatSnapshot = async (request: PublicChatSnapshotDeleteRequest): Promise<void> => {
const parsed = publicChatSnapshotDeleteRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
@ -63,6 +71,25 @@ class ChatThreadsApiService {
`/api/v1/threads/${parsed.data.thread_id}/snapshots/${parsed.data.snapshot_id}`
);
};
/**
* List all public chat snapshots for a search space.
*/
listPublicChatSnapshotsForSearchSpace = async (
request: PublicChatSnapshotsBySpaceRequest
): Promise<PublicChatSnapshotsBySpaceResponse> => {
const parsed = publicChatSnapshotsBySpaceRequest.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/searchspaces/${parsed.data.search_space_id}/snapshots`,
publicChatSnapshotsBySpaceResponse
);
};
}
export const chatThreadsApiService = new ChatThreadsApiService();

View file

@ -82,4 +82,9 @@ export const cacheKeys = {
publicChat: {
byToken: (shareToken: string) => ["public-chat", shareToken] as const,
},
publicChatSnapshots: {
all: ["public-chat-snapshots"] as const,
bySearchSpace: (searchSpaceId: number) =>
["public-chat-snapshots", "search-space", searchSpaceId] as const,
},
};

View file

@ -731,6 +731,8 @@
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_system_instructions": "System Instructions",
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
"nav_public_links": "Public Chat Links",
"nav_public_links_desc": "Manage publicly shared chat links",
"general_name_label": "Name",
"general_name_placeholder": "Enter search space name",
"general_name_description": "A unique name for your search space.",
@ -794,5 +796,10 @@
"comments": "comments",
"example_comment": "Let's discuss this tomorrow!"
}
},
"public_chat": {
"not_found_title": "This chat has been deleted.",
"click_here": "Click here",
"sign_in_prompt": "to log in to SurfSense and start your own."
}
}

View file

@ -716,6 +716,8 @@
"nav_role_assignments_desc": "为代理角色分配配置",
"nav_system_instructions": "系统指令",
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
"nav_public_links": "公开聊天链接",
"nav_public_links_desc": "管理公开分享的聊天链接",
"general_name_label": "名称",
"general_name_placeholder": "输入搜索空间名称",
"general_name_description": "您的搜索空间的唯一名称。",
@ -779,5 +781,10 @@
"comments": "评论",
"example_comment": "我们明天讨论这个!"
}
},
"public_chat": {
"not_found_title": "此对话已被删除。",
"click_here": "点击这里",
"sign_in_prompt": "登录 SurfSense 开始您自己的对话。"
}
}