mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-04 22:02:16 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/documents
This commit is contained in:
commit
103baa8b7a
41 changed files with 2054 additions and 475 deletions
|
|
@ -239,6 +239,7 @@ ENV POSTGRES_DB=surfsense
|
||||||
ENV DATABASE_URL=postgresql+asyncpg://surfsense:surfsense@localhost:5432/surfsense
|
ENV DATABASE_URL=postgresql+asyncpg://surfsense:surfsense@localhost:5432/surfsense
|
||||||
ENV CELERY_BROKER_URL=redis://localhost:6379/0
|
ENV CELERY_BROKER_URL=redis://localhost:6379/0
|
||||||
ENV CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
ENV CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
||||||
|
ENV CELERY_TASK_DEFAULT_QUEUE=surfsense
|
||||||
ENV PYTHONPATH=/app/backend
|
ENV PYTHONPATH=/app/backend
|
||||||
ENV NEXT_FRONTEND_URL=http://localhost:3000
|
ENV NEXT_FRONTEND_URL=http://localhost:3000
|
||||||
ENV AUTH_TYPE=LOCAL
|
ENV AUTH_TYPE=LOCAL
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ services:
|
||||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-surfsense}
|
- 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_BROKER_URL=redis://redis:${REDIS_PORT:-6379}/0
|
||||||
- CELERY_RESULT_BACKEND=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
|
- PYTHONPATH=/app
|
||||||
- UVICORN_LOOP=asyncio
|
- UVICORN_LOOP=asyncio
|
||||||
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
|
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense
|
||||||
#Celery Config
|
#Celery Config
|
||||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||||
CELERY_RESULT_BACKEND=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(for migrations only)
|
||||||
ELECTRIC_DB_USER=electric
|
ELECTRIC_DB_USER=electric
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ Create Date: 2026-02-02
|
||||||
Changes:
|
Changes:
|
||||||
1. Add created_by_id column (UUID, nullable, foreign key to user.id)
|
1. Add created_by_id column (UUID, nullable, foreign key to user.id)
|
||||||
2. Create index on created_by_id for performance
|
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
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|
@ -20,11 +23,15 @@ down_revision: str | None = "85"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# Batch size for backfill operation
|
||||||
|
BATCH_SIZE = 5000
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Add created_by_id column to documents and backfill with search space owner."""
|
"""Add created_by_id column to documents and backfill with search space owner."""
|
||||||
|
|
||||||
# 1. Add created_by_id column (nullable for backward compatibility)
|
# 1. Add created_by_id column (nullable for backward compatibility)
|
||||||
|
print("Step 1/4: Adding created_by_id column...")
|
||||||
op.execute(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
DO $$
|
DO $$
|
||||||
|
|
@ -39,17 +46,21 @@ def upgrade() -> None:
|
||||||
END$$;
|
END$$;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
print(" Done: created_by_id column added.")
|
||||||
|
|
||||||
# 2. Create index on created_by_id for efficient queries
|
# 2. Create index on created_by_id for efficient queries
|
||||||
|
print("Step 2/4: Creating index on created_by_id...")
|
||||||
op.execute(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS ix_documents_created_by_id
|
CREATE INDEX IF NOT EXISTS ix_documents_created_by_id
|
||||||
ON documents (created_by_id);
|
ON documents (created_by_id);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
print(" Done: Index created.")
|
||||||
|
|
||||||
# 3. Add foreign key constraint with ON DELETE SET NULL
|
# 3. Add foreign key constraint with ON DELETE SET NULL
|
||||||
# First check if constraint already exists
|
# First check if constraint already exists
|
||||||
|
print("Step 3/4: Adding foreign key constraint...")
|
||||||
op.execute(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
DO $$
|
DO $$
|
||||||
|
|
@ -67,18 +78,69 @@ def upgrade() -> None:
|
||||||
END$$;
|
END$$;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
print(" Done: Foreign key constraint added.")
|
||||||
|
|
||||||
# 4. Backfill existing documents with search space owner's user_id
|
# 4. Backfill existing documents with search space owner's user_id
|
||||||
# This ensures all existing documents are associated with the search space owner
|
# Process in batches with progress indicator
|
||||||
op.execute(
|
print("Step 4/4: Backfilling created_by_id for existing documents...")
|
||||||
"""
|
|
||||||
UPDATE documents
|
connection = op.get_bind()
|
||||||
SET created_by_id = searchspaces.user_id
|
|
||||||
FROM searchspaces
|
# Get total count of documents that need backfilling
|
||||||
WHERE documents.search_space_id = searchspaces.id
|
result = connection.execute(
|
||||||
AND documents.created_by_id IS NULL;
|
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:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
@ -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;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
@ -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'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -21,8 +21,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.db import Podcast, PodcastStatus
|
from app.db import Podcast, PodcastStatus
|
||||||
|
|
||||||
# Redis connection for tracking active podcast tasks
|
# Redis connection for tracking active podcast tasks
|
||||||
# Uses the same Redis instance as Celery
|
# Defaults to the Celery broker when REDIS_APP_URL is not set
|
||||||
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.Redis | None = None
|
_redis_client: redis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ def init_worker(**kwargs):
|
||||||
# Get Celery configuration from environment
|
# Get Celery configuration from environment
|
||||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
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_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
|
# Get schedule checker interval from environment
|
||||||
# Format: "<number><unit>" where unit is 'm' (minutes) or 'h' (hours)
|
# Format: "<number><unit>" where unit is 'm' (minutes) or 'h' (hours)
|
||||||
|
|
@ -92,6 +93,9 @@ celery_app.conf.update(
|
||||||
result_serializer="json",
|
result_serializer="json",
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
enable_utc=True,
|
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 execution settings
|
||||||
task_track_started=True,
|
task_track_started=True,
|
||||||
task_time_limit=28800, # 8 hour hard limit
|
task_time_limit=28800, # 8 hour hard limit
|
||||||
|
|
|
||||||
|
|
@ -122,8 +122,52 @@ global_llm_configs:
|
||||||
use_default_system_instructions: false
|
use_default_system_instructions: false
|
||||||
citations_enabled: true
|
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
|
- 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"
|
name: "Global Groq Llama 3"
|
||||||
description: "Ultra-fast Llama 3 70B via Groq"
|
description: "Ultra-fast Llama 3 70B via Groq"
|
||||||
provider: "GROQ"
|
provider: "GROQ"
|
||||||
|
|
@ -150,3 +194,11 @@ global_llm_configs:
|
||||||
# - All standard LiteLLM providers are supported
|
# - All standard LiteLLM providers are supported
|
||||||
# - rpm/tpm: Optional rate limits for load balancing (requests/tokens per minute)
|
# - rpm/tpm: Optional rate limits for load balancing (requests/tokens per minute)
|
||||||
# These help the router distribute load evenly and avoid rate limit errors
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,11 @@ class Permission(str, Enum):
|
||||||
SETTINGS_UPDATE = "settings:update"
|
SETTINGS_UPDATE = "settings:update"
|
||||||
SETTINGS_DELETE = "settings:delete" # Delete the entire search space
|
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 wildcard
|
||||||
FULL_ACCESS = "*"
|
FULL_ACCESS = "*"
|
||||||
|
|
||||||
|
|
@ -299,6 +304,9 @@ DEFAULT_ROLE_PERMISSIONS = {
|
||||||
Permission.ROLES_READ.value,
|
Permission.ROLES_READ.value,
|
||||||
# Settings (view only, no update or delete)
|
# Settings (view only, no update or delete)
|
||||||
Permission.SETTINGS_VIEW.value,
|
Permission.SETTINGS_VIEW.value,
|
||||||
|
# Public Sharing (can create and view, no delete)
|
||||||
|
Permission.PUBLIC_SHARING_VIEW.value,
|
||||||
|
Permission.PUBLIC_SHARING_CREATE.value,
|
||||||
],
|
],
|
||||||
"Viewer": [
|
"Viewer": [
|
||||||
# Documents (read only)
|
# Documents (read only)
|
||||||
|
|
@ -322,6 +330,8 @@ DEFAULT_ROLE_PERMISSIONS = {
|
||||||
Permission.ROLES_READ.value,
|
Permission.ROLES_READ.value,
|
||||||
# Settings (view only)
|
# Settings (view only)
|
||||||
Permission.SETTINGS_VIEW.value,
|
Permission.SETTINGS_VIEW.value,
|
||||||
|
# Public Sharing (view only)
|
||||||
|
Permission.PUBLIC_SHARING_VIEW.value,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,9 @@ from app.schemas.new_chat import (
|
||||||
NewChatThreadUpdate,
|
NewChatThreadUpdate,
|
||||||
NewChatThreadVisibilityUpdate,
|
NewChatThreadVisibilityUpdate,
|
||||||
NewChatThreadWithMessages,
|
NewChatThreadWithMessages,
|
||||||
|
PublicChatSnapshotCreateResponse,
|
||||||
|
PublicChatSnapshotListResponse,
|
||||||
RegenerateRequest,
|
RegenerateRequest,
|
||||||
SnapshotCreateResponse,
|
|
||||||
SnapshotListResponse,
|
|
||||||
ThreadHistoryLoadResponse,
|
ThreadHistoryLoadResponse,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadListResponse,
|
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(
|
async def create_thread_snapshot(
|
||||||
thread_id: int,
|
thread_id: int,
|
||||||
request: Request,
|
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
|
@ -747,23 +748,21 @@ async def create_thread_snapshot(
|
||||||
Create a public snapshot of the thread.
|
Create a public snapshot of the thread.
|
||||||
|
|
||||||
Returns existing snapshot URL if content unchanged (deduplication).
|
Returns existing snapshot URL if content unchanged (deduplication).
|
||||||
Only the thread owner can create snapshots.
|
|
||||||
"""
|
"""
|
||||||
from app.services.public_chat_service import create_snapshot
|
from app.services.public_chat_service import create_snapshot
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
|
||||||
return await create_snapshot(
|
return await create_snapshot(
|
||||||
session=session,
|
session=session,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
user=user,
|
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(
|
async def list_thread_snapshots(
|
||||||
thread_id: int,
|
thread_id: int,
|
||||||
request: Request,
|
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
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
|
from app.services.public_chat_service import list_snapshots_for_thread
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
return PublicChatSnapshotListResponse(
|
||||||
return SnapshotListResponse(
|
|
||||||
snapshots=await list_snapshots_for_thread(
|
snapshots=await list_snapshots_for_thread(
|
||||||
session=session,
|
session=session,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
user=user,
|
user=user,
|
||||||
base_url=base_url,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,10 @@ def get_heartbeat_redis_client() -> redis.Redis:
|
||||||
"""Get or create Redis client for heartbeat tracking."""
|
"""Get or create Redis client for heartbeat tracking."""
|
||||||
global _heartbeat_redis_client
|
global _heartbeat_redis_client
|
||||||
if _heartbeat_redis_client is None:
|
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)
|
_heartbeat_redis_client = redis.from_url(redis_url, decode_responses=True)
|
||||||
return _heartbeat_redis_client
|
return _heartbeat_redis_client
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -501,3 +501,25 @@ async def update_llm_preferences(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to update LLM preferences: {e!s}"
|
status_code=500, detail=f"Failed to update LLM preferences: {e!s}"
|
||||||
) from e
|
) 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)
|
||||||
|
|
|
||||||
|
|
@ -211,17 +211,17 @@ class RegenerateRequest(BaseModel):
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class SnapshotCreateResponse(BaseModel):
|
class PublicChatSnapshotCreateResponse(BaseModel):
|
||||||
"""Response after creating a public snapshot."""
|
"""Response after creating a public chat snapshot."""
|
||||||
|
|
||||||
snapshot_id: int
|
snapshot_id: int
|
||||||
share_token: str
|
share_token: str
|
||||||
public_url: str
|
public_url: str
|
||||||
is_new: bool # False if existing snapshot returned (same content)
|
is_new: bool
|
||||||
|
|
||||||
|
|
||||||
class SnapshotInfo(BaseModel):
|
class PublicChatSnapshotInfo(BaseModel):
|
||||||
"""Info about a single snapshot."""
|
"""Info about a single public chat snapshot."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
share_token: str
|
share_token: str
|
||||||
|
|
@ -230,10 +230,28 @@ class SnapshotInfo(BaseModel):
|
||||||
message_count: int
|
message_count: int
|
||||||
|
|
||||||
|
|
||||||
class SnapshotListResponse(BaseModel):
|
class PublicChatSnapshotListResponse(BaseModel):
|
||||||
"""List of snapshots for a thread."""
|
"""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]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,14 @@ from app.db import (
|
||||||
ChatVisibility,
|
ChatVisibility,
|
||||||
NewChatMessage,
|
NewChatMessage,
|
||||||
NewChatThread,
|
NewChatThread,
|
||||||
|
Permission,
|
||||||
Podcast,
|
Podcast,
|
||||||
PodcastStatus,
|
PodcastStatus,
|
||||||
PublicChatSnapshot,
|
PublicChatSnapshot,
|
||||||
SearchSpaceMembership,
|
SearchSpaceMembership,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
UI_TOOLS = {
|
UI_TOOLS = {
|
||||||
"display_image",
|
"display_image",
|
||||||
|
|
@ -159,7 +161,6 @@ async def create_snapshot(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
thread_id: int,
|
thread_id: int,
|
||||||
user: User,
|
user: User,
|
||||||
base_url: str,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Create a public snapshot of a chat thread.
|
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 existing snapshot if content unchanged (same hash).
|
||||||
Returns new snapshot with unique URL if content changed.
|
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(
|
result = await session.execute(
|
||||||
select(NewChatThread)
|
select(NewChatThread)
|
||||||
.options(selectinload(NewChatThread.messages))
|
.options(selectinload(NewChatThread.messages))
|
||||||
|
|
@ -177,11 +181,13 @@ async def create_snapshot(
|
||||||
if not thread:
|
if not thread:
|
||||||
raise HTTPException(status_code=404, detail="Thread not found")
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
if thread.created_by_id != user.id:
|
await check_permission(
|
||||||
raise HTTPException(
|
session,
|
||||||
status_code=403,
|
user,
|
||||||
detail="Only the creator of this chat can create public snapshots",
|
thread.search_space_id,
|
||||||
)
|
Permission.PUBLIC_SHARING_CREATE.value,
|
||||||
|
"You don't have permission to create public share links",
|
||||||
|
)
|
||||||
|
|
||||||
# Build snapshot data
|
# Build snapshot data
|
||||||
user_cache: dict[UUID, dict] = {}
|
user_cache: dict[UUID, dict] = {}
|
||||||
|
|
@ -246,7 +252,7 @@ async def create_snapshot(
|
||||||
return {
|
return {
|
||||||
"snapshot_id": existing.id,
|
"snapshot_id": existing.id,
|
||||||
"share_token": existing.share_token,
|
"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,
|
"is_new": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,7 +285,7 @@ async def create_snapshot(
|
||||||
return {
|
return {
|
||||||
"snapshot_id": snapshot.id,
|
"snapshot_id": snapshot.id,
|
||||||
"share_token": snapshot.share_token,
|
"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,
|
"is_new": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,10 +354,10 @@ async def list_snapshots_for_thread(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
thread_id: int,
|
thread_id: int,
|
||||||
user: User,
|
user: User,
|
||||||
base_url: str,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List all public snapshots for a thread."""
|
"""List all public snapshots for a thread."""
|
||||||
# Verify ownership
|
from app.config import config
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(NewChatThread).filter(NewChatThread.id == thread_id)
|
select(NewChatThread).filter(NewChatThread.id == thread_id)
|
||||||
)
|
)
|
||||||
|
|
@ -366,7 +372,6 @@ async def list_snapshots_for_thread(
|
||||||
detail="Only the creator can view snapshots",
|
detail="Only the creator can view snapshots",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get snapshots
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(PublicChatSnapshot)
|
select(PublicChatSnapshot)
|
||||||
.filter(PublicChatSnapshot.thread_id == thread_id)
|
.filter(PublicChatSnapshot.thread_id == thread_id)
|
||||||
|
|
@ -374,11 +379,13 @@ async def list_snapshots_for_thread(
|
||||||
)
|
)
|
||||||
snapshots = result.scalars().all()
|
snapshots = result.scalars().all()
|
||||||
|
|
||||||
|
frontend_url = (config.NEXT_FRONTEND_URL or "").rstrip("/")
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": s.id,
|
"id": s.id,
|
||||||
"share_token": s.share_token,
|
"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,
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
"message_count": len(s.message_ids) if s.message_ids else 0,
|
"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
|
# Snapshot Deletion
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -412,11 +467,13 @@ async def delete_snapshot(
|
||||||
if not snapshot:
|
if not snapshot:
|
||||||
raise HTTPException(status_code=404, detail="Snapshot not found")
|
raise HTTPException(status_code=404, detail="Snapshot not found")
|
||||||
|
|
||||||
if snapshot.thread.created_by_id != user.id:
|
await check_permission(
|
||||||
raise HTTPException(
|
session,
|
||||||
status_code=403,
|
user,
|
||||||
detail="Only the creator can delete snapshots",
|
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.delete(snapshot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,28 @@ def process_file_upload_task(
|
||||||
user_id: ID of the user
|
user_id: ID of the user
|
||||||
"""
|
"""
|
||||||
import asyncio
|
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()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
|
@ -331,6 +353,15 @@ def process_file_upload_task(
|
||||||
loop.run_until_complete(
|
loop.run_until_complete(
|
||||||
_process_file_upload(file_path, filename, search_space_id, user_id)
|
_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:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
|
@ -343,16 +374,22 @@ async def _process_file_upload(
|
||||||
|
|
||||||
from app.tasks.document_processors.file_processors import process_file_in_background
|
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:
|
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)
|
task_logger = TaskLoggingService(session, search_space_id)
|
||||||
|
|
||||||
# Get file size for notification metadata
|
# Get file size for notification metadata
|
||||||
try:
|
try:
|
||||||
file_size = os.path.getsize(file_path)
|
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
|
file_size = None
|
||||||
|
|
||||||
# Create notification for document processing
|
# Create notification for document processing
|
||||||
|
logger.info(f"[_process_file_upload] Creating notification for: {filename}")
|
||||||
notification = (
|
notification = (
|
||||||
await NotificationService.document_processing.notify_processing_started(
|
await NotificationService.document_processing.notify_processing_started(
|
||||||
session=session,
|
session=session,
|
||||||
|
|
@ -363,6 +400,9 @@ async def _process_file_upload(
|
||||||
file_size=file_size,
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_file_upload",
|
task_name="process_file_upload",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,10 @@ def _clear_generating_podcast(search_space_id: int) -> None:
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
try:
|
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)
|
client = redis.from_url(redis_url, decode_responses=True)
|
||||||
key = f"podcast:generating:{search_space_id}"
|
key = f"podcast:generating:{search_space_id}"
|
||||||
client.delete(key)
|
client.delete(key)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ def get_redis_client() -> redis.Redis:
|
||||||
"""Get or create Redis client for heartbeat checking."""
|
"""Get or create Redis client for heartbeat checking."""
|
||||||
global _redis_client
|
global _redis_client
|
||||||
if _redis_client is None:
|
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)
|
_redis_client = redis.from_url(redis_url, decode_responses=True)
|
||||||
return _redis_client
|
return _redis_client
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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",
|
"static-ffmpeg>=2.13",
|
||||||
"tavily-python>=0.3.2",
|
"tavily-python>=0.3.2",
|
||||||
"unstructured-client>=0.30.0",
|
|
||||||
"unstructured[all-docs]>=0.16.25",
|
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
"validators>=0.34.0",
|
"validators>=0.34.0",
|
||||||
"youtube-transcript-api>=1.0.3",
|
"youtube-transcript-api>=1.0.3",
|
||||||
|
|
@ -45,7 +43,6 @@ dependencies = [
|
||||||
"firecrawl-py>=4.9.0",
|
"firecrawl-py>=4.9.0",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"langchain-community>=0.3.31",
|
"langchain-community>=0.3.31",
|
||||||
"langchain-unstructured>=1.0.0",
|
|
||||||
"litellm>=1.80.10",
|
"litellm>=1.80.10",
|
||||||
"langchain-litellm>=0.3.5",
|
"langchain-litellm>=0.3.5",
|
||||||
"fake-useragent>=2.2.0",
|
"fake-useragent>=2.2.0",
|
||||||
|
|
@ -62,6 +59,9 @@ dependencies = [
|
||||||
"deepagents>=0.3.8",
|
"deepagents>=0.3.8",
|
||||||
"langchain>=1.2.6",
|
"langchain>=1.2.6",
|
||||||
"langgraph>=1.0.5",
|
"langgraph>=1.0.5",
|
||||||
|
"unstructured[all-docs]>=0.18.31",
|
||||||
|
"unstructured-client>=0.42.3",
|
||||||
|
"langchain-unstructured>=1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ backend_pid=$!
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
||||||
echo "Starting Celery Worker..."
|
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=$!
|
celery_worker_pid=$!
|
||||||
|
|
||||||
# Wait a bit for worker to initialize
|
# Wait a bit for worker to initialize
|
||||||
|
|
|
||||||
840
surfsense_backend/uv.lock
generated
840
surfsense_backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Brain,
|
Brain,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
|
Globe,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Menu,
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
|
@ -16,6 +17,7 @@ import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||||
|
|
@ -56,6 +58,12 @@ const settingsNavItems: SettingsNavItem[] = [
|
||||||
descriptionKey: "nav_system_instructions_desc",
|
descriptionKey: "nav_system_instructions_desc",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "public-links",
|
||||||
|
labelKey: "nav_public_links",
|
||||||
|
descriptionKey: "nav_public_links_desc",
|
||||||
|
icon: Globe,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function SettingsSidebar({
|
function SettingsSidebar({
|
||||||
|
|
@ -276,6 +284,9 @@ function SettingsContent({
|
||||||
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
||||||
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
||||||
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
||||||
|
{activeSection === "public-links" && (
|
||||||
|
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Crown,
|
Crown,
|
||||||
Edit2,
|
Edit2,
|
||||||
FileText,
|
FileText,
|
||||||
|
Globe,
|
||||||
Hash,
|
Hash,
|
||||||
Link2,
|
Link2,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
|
|
@ -206,7 +207,15 @@ export default function TeamManagementPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateRole = useCallback(
|
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 = {
|
const request: UpdateRoleRequest = {
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
role_id: roleId,
|
role_id: roleId,
|
||||||
|
|
@ -827,6 +836,12 @@ const CATEGORY_CONFIG: Record<
|
||||||
description: "Manage search space settings",
|
description: "Manage search space settings",
|
||||||
order: 10,
|
order: 10,
|
||||||
},
|
},
|
||||||
|
public_sharing: {
|
||||||
|
label: "Public Chat Sharing",
|
||||||
|
icon: Globe,
|
||||||
|
description: "Share chats publicly via links",
|
||||||
|
order: 11,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
|
@ -944,7 +959,7 @@ function RolesTab({
|
||||||
roles,
|
roles,
|
||||||
groupedPermissions,
|
groupedPermissions,
|
||||||
loading,
|
loading,
|
||||||
onUpdateRole: _onUpdateRole,
|
onUpdateRole,
|
||||||
onDeleteRole,
|
onDeleteRole,
|
||||||
onCreateRole,
|
onCreateRole,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
|
|
@ -954,7 +969,15 @@ function RolesTab({
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
groupedPermissions: Record<string, PermissionWithDescription[]>;
|
groupedPermissions: Record<string, PermissionWithDescription[]>;
|
||||||
loading: boolean;
|
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>;
|
onDeleteRole: (roleId: number) => Promise<boolean>;
|
||||||
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
|
|
@ -962,6 +985,7 @@ function RolesTab({
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [showCreateRole, setShowCreateRole] = useState(false);
|
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||||
|
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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 */}
|
{/* Roles Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{roles.map((role, index) => (
|
{roles.map((role, index) => (
|
||||||
|
|
@ -1055,13 +1094,9 @@ function RolesTab({
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
|
||||||
onClick={() => {
|
|
||||||
// TODO: Implement edit role dialog/modal
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4 mr-2" />
|
<Edit2 className="h-4 w-4 mr-2" />
|
||||||
Edit Role
|
Edit Role
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -2026,3 +2061,371 @@ function CreateRoleSection({
|
||||||
</motion.div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
@ -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");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -536,10 +536,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
/>
|
/>
|
||||||
{/* Placeholder */}
|
{/* Placeholder with fade animation on change */}
|
||||||
{isEmpty && (
|
{isEmpty && (
|
||||||
<div
|
<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"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{placeholder}
|
{placeholder}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface ThreadProps {
|
||||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||||
header?: React.ReactNode;
|
header?: React.ReactNode;
|
||||||
|
|
@ -228,6 +238,30 @@ const Composer: FC = () => {
|
||||||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
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
|
// Live collaboration state
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
const { data: members } = useAtomValue(membersAtom);
|
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">
|
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
||||||
<InlineMentionEditor
|
<InlineMentionEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
placeholder="Ask SurfSense or @mention docs"
|
placeholder={currentPlaceholder}
|
||||||
onMentionTrigger={handleMentionTrigger}
|
onMentionTrigger={handleMentionTrigger}
|
||||||
onMentionClose={handleMentionClose}
|
onMentionClose={handleMentionClose}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Globe, User, Users } from "lucide-react";
|
import { Globe, User, Users } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
|
|
||||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
@ -53,9 +54,17 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
|
|
||||||
// Snapshot creation mutation
|
// Snapshot creation mutation
|
||||||
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
|
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
|
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||||
|
|
||||||
|
|
@ -183,35 +192,39 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Divider */}
|
{canCreatePublicLink && (
|
||||||
<div className="border-t border-border my-1" />
|
<>
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border my-1" />
|
||||||
|
|
||||||
{/* Public Link Option */}
|
{/* Public Link Option */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreatePublicLink}
|
onClick={handleCreatePublicLink}
|
||||||
disabled={isCreatingSnapshot}
|
disabled={isCreatingSnapshot}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
"focus:outline-none",
|
"focus:outline-none",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
||||||
<Globe className="size-4 block text-muted-foreground" />
|
<Globe className="size-4 block text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left min-w-0">
|
<div className="flex-1 text-left min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
Creates a shareable snapshot of this chat
|
Creates a shareable snapshot of this chat
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||||
import { usePublicChat } from "@/hooks/use-public-chat";
|
import { usePublicChat } from "@/hooks/use-public-chat";
|
||||||
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
|
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
|
||||||
import { PublicChatFooter } from "./public-chat-footer";
|
import { PublicChatFooter } from "./public-chat-footer";
|
||||||
|
import { PublicChatNotFound } from "./public-chat-not-found";
|
||||||
import { PublicThread } from "./public-thread";
|
import { PublicThread } from "./public-thread";
|
||||||
|
|
||||||
interface PublicChatViewProps {
|
interface PublicChatViewProps {
|
||||||
|
|
@ -32,17 +33,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return (
|
return <PublicChatNotFound />;
|
||||||
<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 (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot info
|
* Public chat snapshot info
|
||||||
*/
|
*/
|
||||||
export const snapshotInfo = z.object({
|
export const publicChatSnapshotInfo = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
share_token: z.string(),
|
share_token: z.string(),
|
||||||
public_url: 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(),
|
thread_id: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createSnapshotResponse = z.object({
|
export const publicChatSnapshotCreateResponse = z.object({
|
||||||
snapshot_id: z.number(),
|
snapshot_id: z.number(),
|
||||||
share_token: z.string(),
|
share_token: z.string(),
|
||||||
public_url: 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(),
|
thread_id: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listSnapshotsResponse = z.object({
|
export const publicChatSnapshotListResponse = z.object({
|
||||||
snapshots: z.array(snapshotInfo),
|
snapshots: z.array(publicChatSnapshotInfo),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete snapshot
|
* Delete public chat snapshot
|
||||||
*/
|
*/
|
||||||
export const deleteSnapshotRequest = z.object({
|
export const publicChatSnapshotDeleteRequest = z.object({
|
||||||
thread_id: z.number(),
|
thread_id: z.number(),
|
||||||
snapshot_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
|
// Type exports
|
||||||
export type SnapshotInfo = z.infer<typeof snapshotInfo>;
|
export type PublicChatSnapshotInfo = z.infer<typeof publicChatSnapshotInfo>;
|
||||||
export type CreateSnapshotRequest = z.infer<typeof createSnapshotRequest>;
|
export type PublicChatSnapshotCreateRequest = z.infer<typeof publicChatSnapshotCreateRequest>;
|
||||||
export type CreateSnapshotResponse = z.infer<typeof createSnapshotResponse>;
|
export type PublicChatSnapshotCreateResponse = z.infer<typeof publicChatSnapshotCreateResponse>;
|
||||||
export type ListSnapshotsRequest = z.infer<typeof listSnapshotsRequest>;
|
export type PublicChatSnapshotListRequest = z.infer<typeof publicChatSnapshotListRequest>;
|
||||||
export type ListSnapshotsResponse = z.infer<typeof listSnapshotsResponse>;
|
export type PublicChatSnapshotListResponse = z.infer<typeof publicChatSnapshotListResponse>;
|
||||||
export type DeleteSnapshotRequest = z.infer<typeof deleteSnapshotRequest>;
|
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>;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,30 @@
|
||||||
import {
|
import {
|
||||||
type CreateSnapshotRequest,
|
type PublicChatSnapshotCreateRequest,
|
||||||
type CreateSnapshotResponse,
|
type PublicChatSnapshotCreateResponse,
|
||||||
createSnapshotRequest,
|
type PublicChatSnapshotDeleteRequest,
|
||||||
createSnapshotResponse,
|
type PublicChatSnapshotListRequest,
|
||||||
type DeleteSnapshotRequest,
|
type PublicChatSnapshotListResponse,
|
||||||
deleteSnapshotRequest,
|
type PublicChatSnapshotsBySpaceRequest,
|
||||||
type ListSnapshotsRequest,
|
type PublicChatSnapshotsBySpaceResponse,
|
||||||
type ListSnapshotsResponse,
|
publicChatSnapshotCreateRequest,
|
||||||
listSnapshotsRequest,
|
publicChatSnapshotCreateResponse,
|
||||||
listSnapshotsResponse,
|
publicChatSnapshotDeleteRequest,
|
||||||
|
publicChatSnapshotListRequest,
|
||||||
|
publicChatSnapshotListResponse,
|
||||||
|
publicChatSnapshotsBySpaceRequest,
|
||||||
|
publicChatSnapshotsBySpaceResponse,
|
||||||
} from "@/contracts/types/chat-threads.types";
|
} from "@/contracts/types/chat-threads.types";
|
||||||
import { ValidationError } from "../error";
|
import { ValidationError } from "../error";
|
||||||
import { baseApiService } from "./base-api.service";
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
||||||
class ChatThreadsApiService {
|
class ChatThreadsApiService {
|
||||||
/**
|
/**
|
||||||
* Create a public snapshot for a thread.
|
* Create a public chat snapshot for a thread.
|
||||||
*/
|
*/
|
||||||
createSnapshot = async (request: CreateSnapshotRequest): Promise<CreateSnapshotResponse> => {
|
createPublicChatSnapshot = async (
|
||||||
const parsed = createSnapshotRequest.safeParse(request);
|
request: PublicChatSnapshotCreateRequest
|
||||||
|
): Promise<PublicChatSnapshotCreateResponse> => {
|
||||||
|
const parsed = publicChatSnapshotCreateRequest.safeParse(request);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||||
|
|
@ -27,15 +33,17 @@ class ChatThreadsApiService {
|
||||||
|
|
||||||
return baseApiService.post(
|
return baseApiService.post(
|
||||||
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
|
`/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> => {
|
listPublicChatSnapshots = async (
|
||||||
const parsed = listSnapshotsRequest.safeParse(request);
|
request: PublicChatSnapshotListRequest
|
||||||
|
): Promise<PublicChatSnapshotListResponse> => {
|
||||||
|
const parsed = publicChatSnapshotListRequest.safeParse(request);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||||
|
|
@ -44,15 +52,15 @@ class ChatThreadsApiService {
|
||||||
|
|
||||||
return baseApiService.get(
|
return baseApiService.get(
|
||||||
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
|
`/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> => {
|
deletePublicChatSnapshot = async (request: PublicChatSnapshotDeleteRequest): Promise<void> => {
|
||||||
const parsed = deleteSnapshotRequest.safeParse(request);
|
const parsed = publicChatSnapshotDeleteRequest.safeParse(request);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
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}`
|
`/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();
|
export const chatThreadsApiService = new ChatThreadsApiService();
|
||||||
|
|
|
||||||
|
|
@ -82,4 +82,9 @@ export const cacheKeys = {
|
||||||
publicChat: {
|
publicChat: {
|
||||||
byToken: (shareToken: string) => ["public-chat", shareToken] as const,
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -731,6 +731,8 @@
|
||||||
"nav_role_assignments_desc": "Assign configs to agent roles",
|
"nav_role_assignments_desc": "Assign configs to agent roles",
|
||||||
"nav_system_instructions": "System Instructions",
|
"nav_system_instructions": "System Instructions",
|
||||||
"nav_system_instructions_desc": "SearchSpace-wide AI 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_label": "Name",
|
||||||
"general_name_placeholder": "Enter search space name",
|
"general_name_placeholder": "Enter search space name",
|
||||||
"general_name_description": "A unique name for your search space.",
|
"general_name_description": "A unique name for your search space.",
|
||||||
|
|
@ -794,5 +796,10 @@
|
||||||
"comments": "comments",
|
"comments": "comments",
|
||||||
"example_comment": "Let's discuss this tomorrow!"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -716,6 +716,8 @@
|
||||||
"nav_role_assignments_desc": "为代理角色分配配置",
|
"nav_role_assignments_desc": "为代理角色分配配置",
|
||||||
"nav_system_instructions": "系统指令",
|
"nav_system_instructions": "系统指令",
|
||||||
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
|
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
|
||||||
|
"nav_public_links": "公开聊天链接",
|
||||||
|
"nav_public_links_desc": "管理公开分享的聊天链接",
|
||||||
"general_name_label": "名称",
|
"general_name_label": "名称",
|
||||||
"general_name_placeholder": "输入搜索空间名称",
|
"general_name_placeholder": "输入搜索空间名称",
|
||||||
"general_name_description": "您的搜索空间的唯一名称。",
|
"general_name_description": "您的搜索空间的唯一名称。",
|
||||||
|
|
@ -779,5 +781,10 @@
|
||||||
"comments": "评论",
|
"comments": "评论",
|
||||||
"example_comment": "我们明天讨论这个!"
|
"example_comment": "我们明天讨论这个!"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"public_chat": {
|
||||||
|
"not_found_title": "此对话已被删除。",
|
||||||
|
"click_here": "点击这里",
|
||||||
|
"sign_in_prompt": "登录 SurfSense 开始您自己的对话。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue