Revert "Merge pull request #686 from AnishSarkar22/feat/replace-logs"

This reverts commit 5963a1125e, reversing
changes made to 0d2a2f8ea1.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-01-16 00:49:33 -08:00
parent 5963a1125e
commit 3418c0e026
84 changed files with 4613 additions and 8266 deletions

View file

@ -9,6 +9,7 @@ FRONTEND_PORT=3000
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 (Default: http://localhost:8000)
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE (Default: LOCAL)
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING (Default: DOCLING)
# Backend Configuration
BACKEND_PORT=8000
@ -18,17 +19,6 @@ POSTGRES_PASSWORD=postgres
POSTGRES_DB=surfsense
POSTGRES_PORT=5432
# Electric-SQL Configuration
ELECTRIC_PORT=5133
# PostgreSQL host for Electric connection
# - 'db' for Docker PostgreSQL (service name in docker-compose)
# - 'host.docker.internal' for local PostgreSQL (recommended when Electric runs in Docker)
# Note: host.docker.internal works on Docker Desktop (Mac/Windows) and can be enabled on Linux
POSTGRES_HOST=db
ELECTRIC_DB_USER=electric
ELECTRIC_DB_PASSWORD=electric_password
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
# pgAdmin Configuration
PGADMIN_PORT=5050
PGADMIN_DEFAULT_EMAIL=admin@surfsense.com

View file

@ -1,11 +1,10 @@
# SurfSense All-in-One Docker Image
# This image bundles PostgreSQL+pgvector, Redis, Electric SQL, Backend, and Frontend
# Usage: docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 -v surfsense-data:/data --name surfsense ghcr.io/modsetter/surfsense:latest
# This image bundles PostgreSQL+pgvector, Redis, Backend, and Frontend
# Usage: docker run -d -p 3000:3000 -p 8000:8000 -v surfsense-data:/data --name surfsense ghcr.io/modsetter/surfsense:latest
#
# Included Services (all run locally by default):
# - PostgreSQL 14 + pgvector (vector database)
# - Redis (task queue)
# - Electric SQL (real-time sync)
# - Docling (document processing, CPU-only, OCR disabled)
# - Kokoro TTS (local text-to-speech for podcasts)
# - Faster-Whisper (local speech-to-text for audio files)
@ -15,12 +14,7 @@
# will be available in the future for faster AI inference.
# ====================
# Stage 1: Get Electric SQL Binary
# ====================
FROM electricsql/electric:latest AS electric-builder
# ====================
# Stage 2: Build Frontend
# Stage 1: Build Frontend
# ====================
FROM node:20-alpine AS frontend-builder
@ -48,14 +42,12 @@ RUN pnpm fumadocs-mdx
ENV NEXT_PUBLIC_FASTAPI_BACKEND_URL=__NEXT_PUBLIC_FASTAPI_BACKEND_URL__
ENV NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
ENV NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__
ENV NEXT_PUBLIC_ELECTRIC_URL=__NEXT_PUBLIC_ELECTRIC_URL__
ENV NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__
# Build
RUN pnpm run build
# ====================
# Stage 3: Runtime Image
# Stage 2: Runtime Image
# ====================
FROM ubuntu:22.04 AS runtime
@ -175,11 +167,6 @@ COPY --from=frontend-builder /app/public ./public
COPY surfsense_web/content/docs /app/surfsense_web/content/docs
# ====================
# Copy Electric SQL Release
# ====================
COPY --from=electric-builder /app /app/electric-release
# ====================
# Setup Backend
# ====================
@ -251,22 +238,11 @@ ENV NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
ENV NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL
ENV NEXT_PUBLIC_ETL_SERVICE=DOCLING
# Electric SQL configuration (ELECTRIC_DATABASE_URL is built dynamically by entrypoint from these values)
ENV ELECTRIC_DB_USER=electric
ENV ELECTRIC_DB_PASSWORD=electric_password
# Note: ELECTRIC_DATABASE_URL is NOT set here - entrypoint builds it dynamically from ELECTRIC_DB_USER/PASSWORD
ENV ELECTRIC_INSECURE=true
ENV ELECTRIC_WRITE_TO_PG_MODE=direct
ENV ELECTRIC_PORT=5133
ENV PORT=5133
ENV NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
ENV NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
# Data volume
VOLUME ["/data"]
# Expose ports (Frontend: 3000, Backend: 8000, Electric: 5133)
EXPOSE 3000 8000 5133
# Expose ports
EXPOSE 3000 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \

View file

@ -7,15 +7,10 @@ services:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/docker/postgresql.conf:/etc/postgresql/postgresql.conf:ro
- ./scripts/docker/init-electric-user.sh:/docker-entrypoint-initdb.d/init-electric-user.sh:ro
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-surfsense}
- ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric}
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
command: postgres -c config_file=/etc/postgresql/postgresql.conf
pgadmin:
image: dpage/pgadmin4
@ -56,14 +51,11 @@ services:
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
- LANGCHAIN_TRACING_V2=false
- LANGSMITH_TRACING=false
- ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric}
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
- NEXT_FRONTEND_URL=http://frontend:3000
depends_on:
- db
- redis
# Run these services separately in production
# Run these services seperately in production
# celery_worker:
# build: ./surfsense_backend
# # image: ghcr.io/modsetter/surfsense_backend:latest
@ -118,23 +110,6 @@ services:
# - redis
# - celery_worker
electric:
image: electricsql/electric:latest
ports:
- "${ELECTRIC_PORT:-5133}:3000"
environment:
- DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-surfsense}?sslmode=disable}
- ELECTRIC_INSECURE=true
- ELECTRIC_WRITE_TO_PG_MODE=direct
restart: unless-stopped
# depends_on:
# - db
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/v1/health"]
interval: 10s
timeout: 5s
retries: 5
frontend:
build:
context: ./surfsense_web
@ -147,12 +122,8 @@ services:
- "${FRONTEND_PORT:-3000}:3000"
env_file:
- ./surfsense_web/.env
environment:
- NEXT_PUBLIC_ELECTRIC_URL=${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:5133}
- NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
depends_on:
- backend
- electric
volumes:
postgres_data:

View file

@ -42,31 +42,6 @@ if [ -z "$STT_SERVICE" ]; then
echo "✅ Using default STT_SERVICE: local/base"
fi
# ================================================
# Set Electric SQL configuration
# ================================================
export ELECTRIC_DB_USER="${ELECTRIC_DB_USER:-electric}"
export ELECTRIC_DB_PASSWORD="${ELECTRIC_DB_PASSWORD:-electric_password}"
if [ -z "$ELECTRIC_DATABASE_URL" ]; then
export ELECTRIC_DATABASE_URL="postgresql://${ELECTRIC_DB_USER}:${ELECTRIC_DB_PASSWORD}@localhost:5432/${POSTGRES_DB:-surfsense}?sslmode=disable"
echo "✅ Electric SQL URL configured dynamically"
else
# Ensure sslmode=disable is in the URL if not already present
if [[ "$ELECTRIC_DATABASE_URL" != *"sslmode="* ]]; then
# Add sslmode=disable (handle both cases: with or without existing query params)
if [[ "$ELECTRIC_DATABASE_URL" == *"?"* ]]; then
export ELECTRIC_DATABASE_URL="${ELECTRIC_DATABASE_URL}&sslmode=disable"
else
export ELECTRIC_DATABASE_URL="${ELECTRIC_DATABASE_URL}?sslmode=disable"
fi
fi
echo "✅ Electric SQL URL configured from environment"
fi
# Set Electric SQL port
export ELECTRIC_PORT="${ELECTRIC_PORT:-5133}"
export PORT="${ELECTRIC_PORT}"
# ================================================
# Initialize PostgreSQL if needed
# ================================================
@ -85,11 +60,6 @@ if [ ! -f /data/postgres/PG_VERSION ]; then
echo "local all all trust" >> /data/postgres/pg_hba.conf
echo "listen_addresses='*'" >> /data/postgres/postgresql.conf
# Enable logical replication for Electric SQL
echo "wal_level = logical" >> /data/postgres/postgresql.conf
echo "max_replication_slots = 10" >> /data/postgres/postgresql.conf
echo "max_wal_senders = 10" >> /data/postgres/postgresql.conf
# Start PostgreSQL temporarily to create database and user
su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data/postgres -l /tmp/postgres_init.log start"
@ -103,35 +73,6 @@ if [ ! -f /data/postgres/PG_VERSION ]; then
# Enable pgvector extension
su - postgres -c "psql -d ${POSTGRES_DB:-surfsense} -c 'CREATE EXTENSION IF NOT EXISTS vector;'"
# Create Electric SQL replication user (idempotent - uses IF NOT EXISTS)
echo "📡 Creating Electric SQL replication user..."
su - postgres -c "psql -d ${POSTGRES_DB:-surfsense} <<-EOSQL
DO \\\$\\\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '${ELECTRIC_DB_USER}') THEN
CREATE USER ${ELECTRIC_DB_USER} WITH REPLICATION PASSWORD '${ELECTRIC_DB_PASSWORD}';
END IF;
END
\\\$\\\$;
GRANT CONNECT ON DATABASE ${POSTGRES_DB:-surfsense} TO ${ELECTRIC_DB_USER};
GRANT USAGE ON SCHEMA public TO ${ELECTRIC_DB_USER};
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${ELECTRIC_DB_USER};
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO ${ELECTRIC_DB_USER};
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${ELECTRIC_DB_USER};
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO ${ELECTRIC_DB_USER};
-- Create the publication for Electric SQL (if not exists)
DO \\\$\\\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_publication WHERE pubname = 'electric_publication_default') THEN
CREATE PUBLICATION electric_publication_default;
END IF;
END
\\\$\\\$;
EOSQL"
echo "✅ Electric SQL user '${ELECTRIC_DB_USER}' created"
# Stop temporary PostgreSQL
su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data/postgres stop"
@ -166,23 +107,18 @@ echo "🔧 Applying runtime environment configuration..."
NEXT_PUBLIC_FASTAPI_BACKEND_URL="${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:8000}"
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE="${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE:-LOCAL}"
NEXT_PUBLIC_ETL_SERVICE="${NEXT_PUBLIC_ETL_SERVICE:-DOCLING}"
NEXT_PUBLIC_ELECTRIC_URL="${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:5133}"
NEXT_PUBLIC_ELECTRIC_AUTH_MODE="${NEXT_PUBLIC_ELECTRIC_AUTH_MODE:-insecure}"
# Replace placeholders in all JS files
find /app/frontend -type f \( -name "*.js" -o -name "*.json" \) -exec sed -i \
-e "s|__NEXT_PUBLIC_FASTAPI_BACKEND_URL__|${NEXT_PUBLIC_FASTAPI_BACKEND_URL}|g" \
-e "s|__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__|${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE}|g" \
-e "s|__NEXT_PUBLIC_ETL_SERVICE__|${NEXT_PUBLIC_ETL_SERVICE}|g" \
-e "s|__NEXT_PUBLIC_ELECTRIC_URL__|${NEXT_PUBLIC_ELECTRIC_URL}|g" \
-e "s|__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__|${NEXT_PUBLIC_ELECTRIC_AUTH_MODE}|g" \
{} +
echo "✅ Environment configuration applied"
echo " Backend URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}"
echo " Auth Type: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE}"
echo " ETL Service: ${NEXT_PUBLIC_ETL_SERVICE}"
echo " Electric URL: ${NEXT_PUBLIC_ELECTRIC_URL}"
echo " Backend URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}"
echo " Auth Type: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE}"
echo " ETL Service: ${NEXT_PUBLIC_ETL_SERVICE}"
# ================================================
# Run database migrations
@ -225,7 +161,6 @@ echo "==========================================="
echo " Frontend URL: http://localhost:3000"
echo " Backend API: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}"
echo " API Docs: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}/docs"
echo " Electric URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:5133}"
echo " Auth Type: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE}"
echo " ETL Service: ${NEXT_PUBLIC_ETL_SERVICE}"
echo " TTS Service: ${TTS_SERVICE}"

View file

@ -1,56 +0,0 @@
#!/bin/sh
# ============================================================================
# Electric SQL User Initialization Script (docker-compose only)
# ============================================================================
# This script is ONLY used when running via docker-compose.
#
# How it works:
# - docker-compose.yml mounts this script into the PostgreSQL container's
# /docker-entrypoint-initdb.d/ directory
# - PostgreSQL automatically executes scripts in that directory on first
# container initialization
#
# For local PostgreSQL users (non-Docker), this script is NOT used.
# Instead, the Electric user is created by Alembic migration 66
# (66_add_notifications_table_and_electric_replication.py).
#
# Both approaches are idempotent (use IF NOT EXISTS), so running both
# will not cause conflicts.
# ============================================================================
set -e
# Use environment variables with defaults
ELECTRIC_DB_USER="${ELECTRIC_DB_USER:-electric}"
ELECTRIC_DB_PASSWORD="${ELECTRIC_DB_PASSWORD:-electric_password}"
echo "Creating Electric SQL replication user: $ELECTRIC_DB_USER"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$ELECTRIC_DB_USER') THEN
CREATE USER $ELECTRIC_DB_USER WITH REPLICATION PASSWORD '$ELECTRIC_DB_PASSWORD';
END IF;
END
\$\$;
GRANT CONNECT ON DATABASE $POSTGRES_DB TO $ELECTRIC_DB_USER;
GRANT CREATE ON DATABASE $POSTGRES_DB TO $ELECTRIC_DB_USER;
GRANT USAGE ON SCHEMA public TO $ELECTRIC_DB_USER;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO $ELECTRIC_DB_USER;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO $ELECTRIC_DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO $ELECTRIC_DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO $ELECTRIC_DB_USER;
-- Create the publication for Electric SQL (if not exists)
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_publication WHERE pubname = 'electric_publication_default') THEN
CREATE PUBLICATION electric_publication_default;
END IF;
END
\$\$;
EOSQL
echo "Electric SQL user '$ELECTRIC_DB_USER' and publication created successfully"

View file

@ -9,10 +9,6 @@ POSTGRES_USER=${POSTGRES_USER:-surfsense}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-surfsense}
POSTGRES_DB=${POSTGRES_DB:-surfsense}
# Electric SQL user credentials (configurable)
ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric}
ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
echo "Initializing PostgreSQL..."
# Check if PostgreSQL is already initialized
@ -27,18 +23,8 @@ fi
# Configure PostgreSQL
cat >> "$PGDATA/postgresql.conf" << EOF
listen_addresses = '*'
max_connections = 200
shared_buffers = 256MB
# Enable logical replication (required for Electric SQL)
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
# Performance settings
checkpoint_timeout = 10min
max_wal_size = 1GB
min_wal_size = 80MB
max_connections = 100
shared_buffers = 128MB
EOF
cat >> "$PGDATA/pg_hba.conf" << EOF
@ -59,15 +45,6 @@ CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD' SUPERUSER;
CREATE DATABASE $POSTGRES_DB OWNER $POSTGRES_USER;
\c $POSTGRES_DB
CREATE EXTENSION IF NOT EXISTS vector;
-- Create Electric SQL replication user
CREATE USER $ELECTRIC_DB_USER WITH REPLICATION PASSWORD '$ELECTRIC_DB_PASSWORD';
GRANT CONNECT ON DATABASE $POSTGRES_DB TO $ELECTRIC_DB_USER;
GRANT USAGE ON SCHEMA public TO $ELECTRIC_DB_USER;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO $ELECTRIC_DB_USER;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO $ELECTRIC_DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO $ELECTRIC_DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO $ELECTRIC_DB_USER;
EOF
echo "PostgreSQL initialized successfully."

View file

@ -1,20 +0,0 @@
# PostgreSQL configuration for Electric SQL
# This file is mounted into the PostgreSQL container
listen_addresses = '*'
max_connections = 200
shared_buffers = 256MB
# Enable logical replication (required for Electric SQL)
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
# Performance settings
checkpoint_timeout = 10min
max_wal_size = 1GB
min_wal_size = 80MB
# Logging (optional, for debugging)
# log_statement = 'all'
# log_replication_commands = on

View file

@ -85,20 +85,6 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=PYTHONPATH="/app/backend"
# Electric SQL (real-time sync)
[program:electric]
command=/app/electric-release/bin/entrypoint start
autostart=true
autorestart=true
priority=25
startsecs=10
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=DATABASE_URL="%(ENV_ELECTRIC_DATABASE_URL)s",ELECTRIC_INSECURE="%(ENV_ELECTRIC_INSECURE)s",ELECTRIC_WRITE_TO_PG_MODE="%(ENV_ELECTRIC_WRITE_TO_PG_MODE)s",RELEASE_COOKIE="surfsense_electric_cookie",PORT="%(ENV_ELECTRIC_PORT)s"
# Frontend
[program:frontend]
command=node server.js
@ -116,6 +102,6 @@ environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0"
# Process Groups
[group:surfsense]
programs=postgresql,redis,electric,backend,celery-worker,celery-beat,frontend
programs=postgresql,redis,backend,celery-worker,celery-beat,frontend
priority=999

View file

@ -25,13 +25,6 @@ database_url = os.getenv("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
# Electric SQL user credentials - centralized configuration for migrations
# These are used by migrations that set up Electric SQL replication
config.set_main_option("electric_db_user", os.getenv("ELECTRIC_DB_USER", "electric"))
config.set_main_option(
"electric_db_password", os.getenv("ELECTRIC_DB_PASSWORD", "electric_password")
)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:

View file

@ -1,172 +0,0 @@
"""Add notifications table and Electric SQL replication
Revision ID: 66
Revises: 65
Creates notifications table and sets up Electric SQL replication
(user, publication, REPLICA IDENTITY FULL) for notifications,
search_source_connectors, and documents tables.
NOTE: Electric SQL user creation is idempotent (uses IF NOT EXISTS).
- Docker deployments: user is pre-created by scripts/docker/init-electric-user.sh
- Local PostgreSQL: user is created here during migration
Both approaches are safe to run together without conflicts as this migraiton is idempotent
"""
from collections.abc import Sequence
from alembic import context, op
# Get Electric SQL user credentials from env.py configuration
_config = context.config
ELECTRIC_DB_USER = _config.get_main_option("electric_db_user", "electric")
ELECTRIC_DB_PASSWORD = _config.get_main_option(
"electric_db_password", "electric_password"
)
# revision identifiers, used by Alembic.
revision: str = "66"
down_revision: str | None = "65"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema - add notifications table and Electric SQL replication."""
# Create notifications table
op.execute(
"""
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
message TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
"""
)
# Create indexes (using IF NOT EXISTS for idempotency)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_notifications_user_id ON notifications (user_id);"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_notifications_read ON notifications (read);"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_notifications_created_at ON notifications (created_at);"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_notifications_user_read ON notifications (user_id, read);"
)
# =====================================================
# Electric SQL Setup - User and Publication
# =====================================================
# Create Electric SQL replication user if not exists
op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '{ELECTRIC_DB_USER}') THEN
CREATE USER {ELECTRIC_DB_USER} WITH REPLICATION PASSWORD '{ELECTRIC_DB_PASSWORD}';
END IF;
END
$$;
"""
)
# Grant necessary permissions to electric user
op.execute(
f"""
DO $$
DECLARE
db_name TEXT := current_database();
BEGIN
EXECUTE format('GRANT CONNECT ON DATABASE %I TO {ELECTRIC_DB_USER}', db_name);
END
$$;
"""
)
op.execute(f"GRANT USAGE ON SCHEMA public TO {ELECTRIC_DB_USER};")
op.execute(f"GRANT SELECT ON ALL TABLES IN SCHEMA public TO {ELECTRIC_DB_USER};")
op.execute(f"GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO {ELECTRIC_DB_USER};")
op.execute(
f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO {ELECTRIC_DB_USER};"
)
op.execute(
f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO {ELECTRIC_DB_USER};"
)
# Create the publication if not exists
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_publication WHERE pubname = 'electric_publication_default') THEN
CREATE PUBLICATION electric_publication_default;
END IF;
END
$$;
"""
)
# =====================================================
# Electric SQL Setup - Table Configuration
# =====================================================
# Set REPLICA IDENTITY FULL (required by Electric SQL for replication)
op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;")
op.execute("ALTER TABLE search_source_connectors REPLICA IDENTITY FULL;")
op.execute("ALTER TABLE documents REPLICA IDENTITY FULL;")
# Add tables to Electric SQL publication for replication
op.execute(
"""
DO $$
BEGIN
-- Add notifications if not already added
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'electric_publication_default'
AND tablename = 'notifications'
) THEN
ALTER PUBLICATION electric_publication_default ADD TABLE notifications;
END IF;
-- Add search_source_connectors if not already added
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'electric_publication_default'
AND tablename = 'search_source_connectors'
) THEN
ALTER PUBLICATION electric_publication_default ADD TABLE search_source_connectors;
END IF;
-- Add documents if not already added
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'electric_publication_default'
AND tablename = 'documents'
) THEN
ALTER PUBLICATION electric_publication_default ADD TABLE documents;
END IF;
END
$$;
"""
)
def downgrade() -> None:
"""Downgrade schema - remove notifications table."""
op.drop_index("ix_notifications_user_read", table_name="notifications")
op.drop_index("ix_notifications_created_at", table_name="notifications")
op.drop_index("ix_notifications_read", table_name="notifications")
op.drop_index("ix_notifications_user_id", table_name="notifications")
op.drop_table("notifications")

View file

@ -574,12 +574,6 @@ class SearchSpace(BaseModel, TimestampMixin):
order_by="Log.id",
cascade="all, delete-orphan",
)
notifications = relationship(
"Notification",
back_populates="search_space",
order_by="Notification.created_at.desc()",
cascade="all, delete-orphan",
)
search_source_connectors = relationship(
"SearchSourceConnector",
back_populates="search_space",
@ -718,39 +712,6 @@ class Log(BaseModel, TimestampMixin):
search_space = relationship("SearchSpace", back_populates="logs")
class Notification(BaseModel, TimestampMixin):
__tablename__ = "notifications"
user_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True
)
type = Column(
String(50), nullable=False
) # 'connector_indexing', 'document_processing', etc.
title = Column(String(200), nullable=False)
message = Column(Text, nullable=False)
read = Column(
Boolean, nullable=False, default=False, server_default=text("false"), index=True
)
notification_metadata = Column("metadata", JSONB, nullable=True, default={})
updated_at = Column(
TIMESTAMP(timezone=True),
nullable=True,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
index=True,
)
user = relationship("User", back_populates="notifications")
search_space = relationship("SearchSpace", back_populates="notifications")
class SearchSpaceRole(BaseModel, TimestampMixin):
"""
Custom roles that can be defined per search space.
@ -895,12 +856,6 @@ if config.AUTH_TYPE == "GOOGLE":
"OAuthAccount", lazy="joined"
)
search_spaces = relationship("SearchSpace", back_populates="user")
notifications = relationship(
"Notification",
back_populates="user",
order_by="Notification.created_at.desc()",
cascade="all, delete-orphan",
)
# RBAC relationships
search_space_memberships = relationship(
@ -938,12 +893,6 @@ else:
class User(SQLAlchemyBaseUserTableUUID, Base):
search_spaces = relationship("SearchSpace", back_populates="user")
notifications = relationship(
"Notification",
back_populates="user",
order_by="Notification.created_at.desc()",
cascade="all, delete-orphan",
)
# RBAC relationships
search_space_memberships = relationship(

View file

@ -25,7 +25,6 @@ from .luma_add_connector_route import router as luma_add_connector_router
from .new_chat_routes import router as new_chat_router
from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router
from .notifications_routes import router as notifications_router
from .notion_add_connector_route import router as notion_add_connector_router
from .podcasts_routes import router as podcasts_router
from .rbac_routes import router as rbac_router
@ -62,4 +61,3 @@ router.include_router(new_llm_config_router) # LLM configs with prompt configur
router.include_router(logs_router)
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
router.include_router(notifications_router) # Notifications with Electric SQL sync

View file

@ -1,102 +0,0 @@
"""
Notifications API routes.
These endpoints allow marking notifications as read.
Electric SQL automatically syncs the changes to all connected clients.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Notification, User, get_async_session
from app.users import current_active_user
router = APIRouter(prefix="/notifications", tags=["notifications"])
class MarkReadResponse(BaseModel):
"""Response for mark as read operations."""
success: bool
message: str
class MarkAllReadResponse(BaseModel):
"""Response for mark all as read operation."""
success: bool
message: str
updated_count: int
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
async def mark_notification_as_read(
notification_id: int,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> MarkReadResponse:
"""
Mark a single notification as read.
Electric SQL will automatically sync this change to all connected clients.
"""
# Verify the notification belongs to the user
result = await session.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user.id,
)
)
notification = result.scalar_one_or_none()
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
)
if notification.read:
return MarkReadResponse(
success=True,
message="Notification already marked as read",
)
# Update the notification
notification.read = True
await session.commit()
return MarkReadResponse(
success=True,
message="Notification marked as read",
)
@router.patch("/read-all", response_model=MarkAllReadResponse)
async def mark_all_notifications_as_read(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> MarkAllReadResponse:
"""
Mark all notifications as read for the current user.
Electric SQL will automatically sync these changes to all connected clients.
"""
# Update all unread notifications for the user
result = await session.execute(
update(Notification)
.where(
Notification.user_id == user.id,
Notification.read == False, # noqa: E712
)
.values(read=True)
)
await session.commit()
updated_count = result.rowcount
return MarkAllReadResponse(
success=True,
message=f"Marked {updated_count} notification(s) as read",
updated_count=updated_count,
)

View file

@ -1,664 +0,0 @@
"""Service for creating and managing notifications with Electric SQL sync."""
import logging
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.db import Notification
logger = logging.getLogger(__name__)
class BaseNotificationHandler:
"""Base class for notification handlers - provides common functionality."""
def __init__(self, notification_type: str):
"""
Initialize the notification handler.
Args:
notification_type: Type of notification (e.g., 'connector_indexing', 'document_processing')
"""
self.notification_type = notification_type
async def find_notification_by_operation(
self,
session: AsyncSession,
user_id: UUID,
operation_id: str,
search_space_id: int | None = None,
) -> Notification | None:
"""
Find an existing notification by operation ID.
Args:
session: Database session
user_id: User ID
operation_id: Unique operation identifier
search_space_id: Optional search space ID
Returns:
Notification if found, None otherwise
"""
query = select(Notification).where(
Notification.user_id == user_id,
Notification.type == self.notification_type,
Notification.notification_metadata["operation_id"].astext == operation_id,
)
if search_space_id is not None:
query = query.where(Notification.search_space_id == search_space_id)
result = await session.execute(query)
return result.scalar_one_or_none()
async def find_or_create_notification(
self,
session: AsyncSession,
user_id: UUID,
operation_id: str,
title: str,
message: str,
search_space_id: int | None = None,
initial_metadata: dict[str, Any] | None = None,
) -> Notification:
"""
Find an existing notification or create a new one.
Args:
session: Database session
user_id: User ID
operation_id: Unique operation identifier
title: Notification title
message: Notification message
search_space_id: Optional search space ID
initial_metadata: Initial metadata dictionary
Returns:
Notification: The found or created notification
"""
# Try to find existing notification
notification = await self.find_notification_by_operation(
session, user_id, operation_id, search_space_id
)
if notification:
# Update existing notification
notification.title = title
notification.message = message
if initial_metadata:
notification.notification_metadata = {
**notification.notification_metadata,
**initial_metadata,
}
# Mark JSONB column as modified so SQLAlchemy detects the change
flag_modified(notification, "notification_metadata")
await session.commit()
await session.refresh(notification)
logger.info(
f"Updated notification {notification.id} for operation {operation_id}"
)
return notification
# Create new notification
metadata = initial_metadata or {}
metadata["operation_id"] = operation_id
metadata["status"] = "in_progress"
metadata["started_at"] = datetime.now(UTC).isoformat()
notification = Notification(
user_id=user_id,
search_space_id=search_space_id,
type=self.notification_type,
title=title,
message=message,
notification_metadata=metadata,
)
session.add(notification)
await session.commit()
await session.refresh(notification)
logger.info(
f"Created notification {notification.id} for operation {operation_id}"
)
return notification
async def update_notification(
self,
session: AsyncSession,
notification: Notification,
title: str | None = None,
message: str | None = None,
status: str | None = None,
metadata_updates: dict[str, Any] | None = None,
) -> Notification:
"""
Update an existing notification.
Args:
session: Database session
notification: Notification to update
title: New title (optional)
message: New message (optional)
status: New status (optional)
metadata_updates: Additional metadata to merge (optional)
Returns:
Updated notification
"""
if title is not None:
notification.title = title
if message is not None:
notification.message = message
if status is not None:
notification.notification_metadata["status"] = status
if status in ("completed", "failed"):
notification.notification_metadata["completed_at"] = datetime.now(
UTC
).isoformat()
# Mark JSONB column as modified so SQLAlchemy detects the change
flag_modified(notification, "notification_metadata")
if metadata_updates:
notification.notification_metadata = {
**notification.notification_metadata,
**metadata_updates,
}
# Mark JSONB column as modified
flag_modified(notification, "notification_metadata")
await session.commit()
await session.refresh(notification)
logger.info(f"Updated notification {notification.id}")
return notification
class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
"""Handler for connector indexing notifications."""
def __init__(self):
super().__init__("connector_indexing")
def _generate_operation_id(
self,
connector_id: int,
start_date: str | None = None,
end_date: str | None = None,
) -> str:
"""
Generate a unique operation ID for a connector indexing operation.
Args:
connector_id: Connector ID
start_date: Start date (optional)
end_date: End date (optional)
Returns:
Unique operation ID string
"""
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
date_range = ""
if start_date or end_date:
date_range = f"_{start_date or 'none'}_{end_date or 'none'}"
return f"connector_{connector_id}_{timestamp}{date_range}"
def _generate_google_drive_operation_id(
self, connector_id: int, folder_count: int, file_count: int
) -> str:
"""
Generate a unique operation ID for a Google Drive indexing operation.
Args:
connector_id: Connector ID
folder_count: Number of folders to index
file_count: Number of files to index
Returns:
Unique operation ID string
"""
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
items_info = f"_{folder_count}f_{file_count}files"
return f"drive_{connector_id}_{timestamp}{items_info}"
async def notify_indexing_started(
self,
session: AsyncSession,
user_id: UUID,
connector_id: int,
connector_name: str,
connector_type: str,
search_space_id: int,
start_date: str | None = None,
end_date: str | None = None,
) -> Notification:
"""
Create or update notification when connector indexing starts.
Args:
session: Database session
user_id: User ID
connector_id: Connector ID
connector_name: Connector name
connector_type: Connector type
search_space_id: Search space ID
start_date: Start date for indexing
end_date: End date for indexing
Returns:
Notification: The created or updated notification
"""
operation_id = self._generate_operation_id(connector_id, start_date, end_date)
title = f"Syncing: {connector_name}"
message = "Connecting to your account"
metadata = {
"connector_id": connector_id,
"connector_name": connector_name,
"connector_type": connector_type,
"start_date": start_date,
"end_date": end_date,
"indexed_count": 0,
"sync_stage": "connecting",
}
return await self.find_or_create_notification(
session=session,
user_id=user_id,
operation_id=operation_id,
title=title,
message=message,
search_space_id=search_space_id,
initial_metadata=metadata,
)
async def notify_indexing_progress(
self,
session: AsyncSession,
notification: Notification,
indexed_count: int,
total_count: int | None = None,
stage: str | None = None,
stage_message: str | None = None,
) -> Notification:
"""
Update notification with indexing progress.
Args:
session: Database session
notification: Notification to update
indexed_count: Number of items indexed so far
total_count: Total number of items (optional)
stage: Current sync stage (fetching, processing, storing) (optional)
stage_message: Optional custom message for the stage
Returns:
Updated notification
"""
# User-friendly stage messages (clean, no ellipsis - spinner shows activity)
stage_messages = {
"connecting": "Connecting to your account",
"fetching": "Fetching your content",
"processing": "Preparing for search",
"storing": "Almost done",
}
# Use stage-based message if stage provided, otherwise fallback
if stage or stage_message:
progress_msg = stage_message or stage_messages.get(stage, "Processing")
else:
# Fallback for backward compatibility
progress_msg = "Fetching your content"
metadata_updates = {"indexed_count": indexed_count}
if total_count is not None:
metadata_updates["total_count"] = total_count
progress_percent = int((indexed_count / total_count) * 100)
metadata_updates["progress_percent"] = progress_percent
if stage:
metadata_updates["sync_stage"] = stage
return await self.update_notification(
session=session,
notification=notification,
message=progress_msg,
status="in_progress",
metadata_updates=metadata_updates,
)
async def notify_indexing_completed(
self,
session: AsyncSession,
notification: Notification,
indexed_count: int,
error_message: str | None = None,
) -> Notification:
"""
Update notification when connector indexing completes.
Args:
session: Database session
notification: Notification to update
indexed_count: Total number of items indexed
error_message: Error message if indexing failed (optional)
Returns:
Updated notification
"""
connector_name = notification.notification_metadata.get(
"connector_name", "Connector"
)
if error_message:
title = f"Failed: {connector_name}"
message = f"Sync failed: {error_message}"
status = "failed"
else:
title = f"Ready: {connector_name}"
if indexed_count == 0:
message = "Already up to date! No new items to sync."
else:
item_text = "item" if indexed_count == 1 else "items"
message = f"Now searchable! {indexed_count} {item_text} synced."
status = "completed"
metadata_updates = {
"indexed_count": indexed_count,
"sync_stage": "completed" if not error_message else "failed",
"error_message": error_message,
}
return await self.update_notification(
session=session,
notification=notification,
title=title,
message=message,
status=status,
metadata_updates=metadata_updates,
)
async def notify_google_drive_indexing_started(
self,
session: AsyncSession,
user_id: UUID,
connector_id: int,
connector_name: str,
connector_type: str,
search_space_id: int,
folder_count: int,
file_count: int,
folder_names: list[str] | None = None,
file_names: list[str] | None = None,
) -> Notification:
"""
Create or update notification when Google Drive indexing starts.
Args:
session: Database session
user_id: User ID
connector_id: Connector ID
connector_name: Connector name
connector_type: Connector type
search_space_id: Search space ID
folder_count: Number of folders to index
file_count: Number of files to index
folder_names: List of folder names (optional)
file_names: List of file names (optional)
Returns:
Notification: The created or updated notification
"""
operation_id = self._generate_google_drive_operation_id(
connector_id, folder_count, file_count
)
title = f"Syncing: {connector_name}"
message = "Preparing your files"
metadata = {
"connector_id": connector_id,
"connector_name": connector_name,
"connector_type": connector_type,
"folder_count": folder_count,
"file_count": file_count,
"indexed_count": 0,
"sync_stage": "connecting",
}
if folder_names:
metadata["folder_names"] = folder_names
if file_names:
metadata["file_names"] = file_names
return await self.find_or_create_notification(
session=session,
user_id=user_id,
operation_id=operation_id,
title=title,
message=message,
search_space_id=search_space_id,
initial_metadata=metadata,
)
class DocumentProcessingNotificationHandler(BaseNotificationHandler):
"""Handler for document processing notifications."""
def __init__(self):
super().__init__("document_processing")
def _generate_operation_id(
self, document_type: str, filename: str, search_space_id: int
) -> str:
"""
Generate a unique operation ID for a document processing operation.
Args:
document_type: Type of document (FILE, YOUTUBE_VIDEO, CRAWLED_URL, etc.)
filename: Name of the file/document
search_space_id: Search space ID
Returns:
Unique operation ID string
"""
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
# Create a short hash of filename to ensure uniqueness
import hashlib
filename_hash = hashlib.md5(filename.encode()).hexdigest()[:8]
return f"doc_{document_type}_{search_space_id}_{timestamp}_{filename_hash}"
async def notify_processing_started(
self,
session: AsyncSession,
user_id: UUID,
document_type: str,
document_name: str,
search_space_id: int,
file_size: int | None = None,
) -> Notification:
"""
Create notification when document processing starts.
Args:
session: Database session
user_id: User ID
document_type: Type of document (FILE, YOUTUBE_VIDEO, CRAWLED_URL, etc.)
document_name: Name/title of the document
search_space_id: Search space ID
file_size: Size of file in bytes (optional)
Returns:
Notification: The created notification
"""
operation_id = self._generate_operation_id(
document_type, document_name, search_space_id
)
title = f"Processing: {document_name}"
message = "Waiting in queue"
metadata = {
"document_type": document_type,
"document_name": document_name,
"processing_stage": "queued",
}
if file_size is not None:
metadata["file_size"] = file_size
return await self.find_or_create_notification(
session=session,
user_id=user_id,
operation_id=operation_id,
title=title,
message=message,
search_space_id=search_space_id,
initial_metadata=metadata,
)
async def notify_processing_progress(
self,
session: AsyncSession,
notification: Notification,
stage: str,
stage_message: str | None = None,
chunks_count: int | None = None,
) -> Notification:
"""
Update notification with processing progress.
Args:
session: Database session
notification: Notification to update
stage: Current processing stage (parsing, chunking, embedding, storing)
stage_message: Optional custom message for the stage
chunks_count: Number of chunks created (optional, stored in metadata only)
Returns:
Updated notification
"""
# User-friendly stage messages
stage_messages = {
"parsing": "Reading your file",
"chunking": "Preparing for search",
"embedding": "Preparing for search",
"storing": "Finalizing",
}
message = stage_message or stage_messages.get(stage, "Processing")
metadata_updates = {"processing_stage": stage}
# Store chunks_count in metadata for debugging, but don't show to user
if chunks_count is not None:
metadata_updates["chunks_count"] = chunks_count
return await self.update_notification(
session=session,
notification=notification,
message=message,
status="in_progress",
metadata_updates=metadata_updates,
)
async def notify_processing_completed(
self,
session: AsyncSession,
notification: Notification,
document_id: int | None = None,
chunks_count: int | None = None,
error_message: str | None = None,
) -> Notification:
"""
Update notification when document processing completes.
Args:
session: Database session
notification: Notification to update
document_id: ID of the created document (optional)
chunks_count: Total number of chunks created (optional)
error_message: Error message if processing failed (optional)
Returns:
Updated notification
"""
document_name = notification.notification_metadata.get(
"document_name", "Document"
)
if error_message:
title = f"Failed: {document_name}"
message = f"Processing failed: {error_message}"
status = "failed"
else:
title = f"Ready: {document_name}"
message = "Now searchable!"
status = "completed"
metadata_updates = {
"processing_stage": "completed" if not error_message else "failed",
"error_message": error_message,
}
if document_id is not None:
metadata_updates["document_id"] = document_id
# Store chunks_count in metadata for debugging, but don't show to user
if chunks_count is not None:
metadata_updates["chunks_count"] = chunks_count
return await self.update_notification(
session=session,
notification=notification,
title=title,
message=message,
status=status,
metadata_updates=metadata_updates,
)
class NotificationService:
"""Service for creating and managing notifications that sync via Electric SQL."""
# Handler instances
connector_indexing = ConnectorIndexingNotificationHandler()
document_processing = DocumentProcessingNotificationHandler()
@staticmethod
async def create_notification(
session: AsyncSession,
user_id: UUID,
notification_type: str,
title: str,
message: str,
search_space_id: int | None = None,
notification_metadata: dict[str, Any] | None = None,
) -> Notification:
"""
Create a notification - Electric SQL will automatically sync it to frontend.
Args:
session: Database session
user_id: User to notify
notification_type: Type of notification (e.g., 'document_processing', 'connector_indexing')
title: Notification title
message: Notification message
search_space_id: Optional search space ID
notification_metadata: Optional metadata dictionary
Returns:
Notification: The created notification
"""
notification = Notification(
user_id=user_id,
search_space_id=search_space_id,
type=notification_type,
title=title,
message=message,
notification_metadata=notification_metadata or {},
)
session.add(notification)
await session.commit()
await session.refresh(notification)
logger.info(f"Created notification {notification.id} for user {user_id}")
return notification

View file

@ -445,13 +445,31 @@ async def _index_google_gmail_messages(
end_date: str,
):
"""Index Google Gmail messages with new session."""
from datetime import datetime
from app.routes.search_source_connectors_routes import (
run_google_gmail_indexing,
)
# Parse dates to calculate days_back
max_messages = 100
days_back = 30 # Default
if start_date:
try:
# Parse start_date (format: YYYY-MM-DD)
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
# Calculate days back from now
days_back = (datetime.now() - start_dt).days
# Ensure at least 1 day
days_back = max(1, days_back)
except ValueError:
# If parsing fails, use default
days_back = 30
async with get_celery_session_maker()() as session:
await run_google_gmail_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
session, connector_id, search_space_id, user_id, max_messages, days_back
)

View file

@ -1,14 +1,12 @@
"""Celery tasks for document processing."""
import logging
from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.celery_app import celery_app
from app.config import config
from app.services.notification_service import NotificationService
from app.services.task_logging_service import TaskLoggingService
from app.tasks.document_processors import (
add_extension_received_document,
@ -86,22 +84,6 @@ async def _process_extension_document(
async with get_celery_session_maker()() as session:
task_logger = TaskLoggingService(session, search_space_id)
# Truncate title for notification display
page_title = individual_document.metadata.VisitedWebPageTitle[:50]
if len(individual_document.metadata.VisitedWebPageTitle) > 50:
page_title += "..."
# Create notification for document processing
notification = (
await NotificationService.document_processing.notify_processing_started(
session=session,
user_id=UUID(user_id),
document_type="EXTENSION",
document_name=page_title,
search_space_id=search_space_id,
)
)
log_entry = await task_logger.log_task_start(
task_name="process_extension_document",
source="document_processor",
@ -115,14 +97,6 @@ async def _process_extension_document(
)
try:
# Update notification: parsing stage
await NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Reading page content",
)
result = await add_extension_received_document(
session, individual_document, search_space_id, user_id
)
@ -133,31 +107,12 @@ async def _process_extension_document(
f"Successfully processed extension document: {individual_document.metadata.VisitedWebPageTitle}",
{"document_id": result.id, "content_hash": result.content_hash},
)
# Update notification on success
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
document_id=result.id,
chunks_count=None,
)
)
else:
await task_logger.log_task_success(
log_entry,
f"Extension document already exists (duplicate): {individual_document.metadata.VisitedWebPageTitle}",
{"duplicate_detected": True},
)
# Update notification for duplicate
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message="Page already saved (duplicate)",
)
)
except Exception as e:
await task_logger.log_task_failure(
log_entry,
@ -165,23 +120,6 @@ async def _process_extension_document(
str(e),
{"error_type": type(e).__name__},
)
# Update notification on failure - wrapped in try-except to ensure it doesn't fail silently
try:
# Refresh notification to ensure it's not stale after any rollback
await session.refresh(notification)
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=str(e)[:100],
)
)
except Exception as notif_error:
logger.error(
f"Failed to update notification on failure: {notif_error!s}"
)
logger.error(f"Error processing extension document: {e!s}")
raise
@ -212,20 +150,6 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
async with get_celery_session_maker()() as session:
task_logger = TaskLoggingService(session, search_space_id)
# Extract video title from URL for notification (will be updated later)
video_name = url.split("v=")[-1][:11] if "v=" in url else url
# Create notification for document processing
notification = (
await NotificationService.document_processing.notify_processing_started(
session=session,
user_id=UUID(user_id),
document_type="YOUTUBE_VIDEO",
document_name=f"YouTube: {video_name}",
search_space_id=search_space_id,
)
)
log_entry = await task_logger.log_task_start(
task_name="process_youtube_video",
source="document_processor",
@ -234,14 +158,6 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
)
try:
# Update notification: parsing (fetching transcript)
await NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Fetching video transcript",
)
result = await add_youtube_video_document(
session, url, search_space_id, user_id
)
@ -256,31 +172,12 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
"content_hash": result.content_hash,
},
)
# Update notification on success
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
document_id=result.id,
chunks_count=None,
)
)
else:
await task_logger.log_task_success(
log_entry,
f"YouTube video document already exists (duplicate): {url}",
{"duplicate_detected": True},
)
# Update notification for duplicate
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message="Video already exists (duplicate)",
)
)
except Exception as e:
await task_logger.log_task_failure(
log_entry,
@ -288,23 +185,6 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
str(e),
{"error_type": type(e).__name__},
)
# Update notification on failure - wrapped in try-except to ensure it doesn't fail silently
try:
# Refresh notification to ensure it's not stale after any rollback
await session.refresh(notification)
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=str(e)[:100],
)
)
except Exception as notif_error:
logger.error(
f"Failed to update notification on failure: {notif_error!s}"
)
logger.error(f"Error processing YouTube video: {e!s}")
raise
@ -339,31 +219,11 @@ async def _process_file_upload(
file_path: str, filename: str, search_space_id: int, user_id: str
):
"""Process file upload with new session."""
import os
from app.tasks.document_processors.file_processors import process_file_in_background
async with get_celery_session_maker()() as session:
task_logger = TaskLoggingService(session, search_space_id)
# Get file size for notification metadata
try:
file_size = os.path.getsize(file_path)
except Exception:
file_size = None
# Create notification for document processing
notification = (
await NotificationService.document_processing.notify_processing_started(
session=session,
user_id=UUID(user_id),
document_type="FILE",
document_name=filename,
search_space_id=search_space_id,
file_size=file_size,
)
)
log_entry = await task_logger.log_task_start(
task_name="process_file_upload",
source="document_processor",
@ -377,7 +237,7 @@ async def _process_file_upload(
)
try:
result = await process_file_in_background(
await process_file_in_background(
file_path,
filename,
search_space_id,
@ -385,29 +245,7 @@ async def _process_file_upload(
session,
task_logger,
log_entry,
notification=notification,
)
# Update notification on success
if result:
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
document_id=result.id,
chunks_count=None,
)
)
else:
# Duplicate detected
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message="Document already exists (duplicate)",
)
)
except Exception as e:
# Import here to avoid circular dependencies
from fastapi import HTTPException
@ -420,23 +258,7 @@ async def _process_file_upload(
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower():
error_message = str(e.detail)
else:
error_message = str(e)[:100]
# Update notification on failure - wrapped in try-except to ensure it doesn't fail silently
try:
# Refresh notification to ensure it's not stale after any rollback
await session.refresh(notification)
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=error_message,
)
)
except Exception as notif_error:
logger.error(
f"Failed to update notification on failure: {notif_error!s}"
)
error_message = f"Failed to process file: {filename}"
await task_logger.log_task_failure(
log_entry,
@ -501,22 +323,6 @@ async def _process_circleback_meeting(
async with get_celery_session_maker()() as session:
task_logger = TaskLoggingService(session, search_space_id)
# Get user_id from metadata if available
user_id = metadata.get("user_id")
# Create notification if user_id is available
notification = None
if user_id:
notification = (
await NotificationService.document_processing.notify_processing_started(
session=session,
user_id=UUID(user_id),
document_type="CIRCLEBACK",
document_name=f"Meeting: {meeting_name[:40]}",
search_space_id=search_space_id,
)
)
log_entry = await task_logger.log_task_start(
task_name="process_circleback_meeting",
source="circleback_webhook",
@ -530,17 +336,6 @@ async def _process_circleback_meeting(
)
try:
# Update notification: parsing stage
if notification:
await (
NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Reading meeting notes",
)
)
result = await add_circleback_meeting_document(
session=session,
meeting_id=meeting_id,
@ -560,29 +355,12 @@ async def _process_circleback_meeting(
"content_hash": result.content_hash,
},
)
# Update notification on success
if notification:
await NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
document_id=result.id,
chunks_count=None,
)
else:
await task_logger.log_task_success(
log_entry,
f"Circleback meeting document already exists (duplicate): {meeting_name}",
{"duplicate_detected": True, "meeting_id": meeting_id},
)
# Update notification for duplicate
if notification:
await NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message="Meeting already saved (duplicate)",
)
except Exception as e:
await task_logger.log_task_failure(
log_entry,
@ -590,21 +368,5 @@ async def _process_circleback_meeting(
str(e),
{"error_type": type(e).__name__, "meeting_id": meeting_id},
)
# Update notification on failure - wrapped in try-except to ensure it doesn't fail silently
if notification:
try:
# Refresh notification to ensure it's not stale after any rollback
await session.refresh(notification)
await NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=str(e)[:100],
)
except Exception as notif_error:
logger.error(
f"Failed to update notification on failure: {notif_error!s}"
)
logger.error(f"Error processing Circleback meeting: {e!s}")
raise

View file

@ -423,9 +423,9 @@ async def stream_new_chat(
title = title[:27] + "..."
doc_names.append(title)
if len(doc_names) == 1:
processing_parts.append(f"[{doc_names[0]}]")
processing_parts.append(f"[📖 {doc_names[0]}]")
else:
processing_parts.append(f"[{len(doc_names)} docs]")
processing_parts.append(f"[📖 {len(doc_names)} docs]")
last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"]

View file

@ -549,10 +549,7 @@ async def index_discord_messages(
logger.info(
f"Discord indexing completed: {documents_indexed} new messages, {documents_skipped} skipped"
)
return (
documents_indexed,
None,
) # Return None on success (result_message is for logging only)
return documents_indexed, result_message
except SQLAlchemyError as db_error:
await session.rollback()

View file

@ -464,10 +464,7 @@ async def index_notion_pages(
# Clean up the async client
await notion_client.close()
return (
total_processed,
None,
) # Return None on success (result_message is for logging only)
return total_processed, result_message
except SQLAlchemyError as db_error:
await session.rollback()

View file

@ -413,10 +413,7 @@ async def index_slack_messages(
logger.info(
f"Slack indexing completed: {documents_indexed} new channels, {documents_skipped} skipped"
)
return (
total_processed,
None,
) # Return None on success (result_message is for logging only)
return total_processed, result_message
except SQLAlchemyError as db_error:
await session.rollback()

View file

@ -460,10 +460,7 @@ async def index_teams_messages(
documents_indexed,
documents_skipped,
)
return (
total_processed,
None,
) # Return None on success (result_message is for logging only)
return total_processed, result_message
except SQLAlchemyError as db_error:
await session.rollback()

View file

@ -371,14 +371,17 @@ async def index_crawled_urls(
)
await session.commit()
# Log failed URLs if any (for debugging purposes)
# Build result message
result_message = None
if failed_urls:
failed_summary = "; ".join(
[f"{url}: {error}" for url, error in failed_urls[:5]]
)
if len(failed_urls) > 5:
failed_summary += f" (and {len(failed_urls) - 5} more)"
logger.warning(f"Some URLs failed to index: {failed_summary}")
result_message = (
f"Completed with {len(failed_urls)} failures: {failed_summary}"
)
await task_logger.log_task_success(
log_entry,
@ -397,10 +400,7 @@ async def index_crawled_urls(
f"{documents_updated} updated, {documents_skipped} skipped, "
f"{len(failed_urls)} failed"
)
return (
total_processed,
None,
) # Return None on success (result_message is for logging only)
return total_processed, result_message
except SQLAlchemyError as db_error:
await session.rollback()

View file

@ -17,9 +17,8 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config as app_config
from app.db import Document, DocumentType, Log, Notification
from app.db import Document, DocumentType, Log
from app.services.llm_service import get_user_long_context_llm
from app.services.notification_service import NotificationService
from app.services.task_logging_service import TaskLoggingService
from app.utils.document_converters import (
convert_document_to_markdown,
@ -595,23 +594,10 @@ async def process_file_in_background(
log_entry: Log,
connector: dict
| None = None, # Optional: {"type": "GOOGLE_DRIVE_FILE", "metadata": {...}}
notification: Notification
| None = None, # Optional notification for progress updates
) -> Document | None:
):
try:
# Check if the file is a markdown or text file
if filename.lower().endswith((".md", ".markdown", ".txt")):
# Update notification: parsing stage
if notification:
await (
NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Reading file",
)
)
await task_logger.log_task_progress(
log_entry,
f"Processing markdown/text file: {filename}",
@ -631,14 +617,6 @@ async def process_file_in_background(
print("Error deleting temp file", e)
pass
# Update notification: chunking stage
if notification:
await (
NotificationService.document_processing.notify_processing_progress(
session, notification, stage="chunking"
)
)
await task_logger.log_task_progress(
log_entry,
f"Creating document from markdown content: {filename}",
@ -666,30 +644,17 @@ async def process_file_in_background(
"file_type": "markdown",
},
)
return result
else:
await task_logger.log_task_success(
log_entry,
f"Markdown file already exists (duplicate): {filename}",
{"duplicate_detected": True, "file_type": "markdown"},
)
return None
# Check if the file is an audio file
elif filename.lower().endswith(
(".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm")
):
# Update notification: parsing stage (transcription)
if notification:
await (
NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Transcribing audio",
)
)
await task_logger.log_task_progress(
log_entry,
f"Processing audio file for transcription: {filename}",
@ -773,14 +738,6 @@ async def process_file_in_background(
},
)
# Update notification: chunking stage
if notification:
await (
NotificationService.document_processing.notify_processing_progress(
session, notification, stage="chunking"
)
)
# Clean up the temp file
try:
os.unlink(file_path)
@ -808,14 +765,12 @@ async def process_file_in_background(
"stt_service": stt_service_type,
},
)
return result
else:
await task_logger.log_task_success(
log_entry,
f"Audio file transcript already exists (duplicate): {filename}",
{"duplicate_detected": True, "file_type": "audio"},
)
return None
else:
# Import page limit service
@ -880,15 +835,6 @@ async def process_file_in_background(
) from e
if app_config.ETL_SERVICE == "UNSTRUCTURED":
# Update notification: parsing stage
if notification:
await NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Extracting content",
)
await task_logger.log_task_progress(
log_entry,
f"Processing file with Unstructured ETL: {filename}",
@ -914,12 +860,6 @@ async def process_file_in_background(
docs = await loader.aload()
# Update notification: chunking stage
if notification:
await NotificationService.document_processing.notify_processing_progress(
session, notification, stage="chunking", chunks_count=len(docs)
)
await task_logger.log_task_progress(
log_entry,
f"Unstructured ETL completed, creating document: {filename}",
@ -979,7 +919,6 @@ async def process_file_in_background(
"pages_processed": final_page_count,
},
)
return result
else:
await task_logger.log_task_success(
log_entry,
@ -990,18 +929,8 @@ async def process_file_in_background(
"etl_service": "UNSTRUCTURED",
},
)
return None
elif app_config.ETL_SERVICE == "LLAMACLOUD":
# Update notification: parsing stage
if notification:
await NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Extracting content",
)
await task_logger.log_task_progress(
log_entry,
f"Processing file with LlamaCloud ETL: {filename}",
@ -1035,15 +964,6 @@ async def process_file_in_background(
split_by_page=False
)
# Update notification: chunking stage
if notification:
await NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="chunking",
chunks_count=len(markdown_documents),
)
await task_logger.log_task_progress(
log_entry,
f"LlamaCloud parsing completed, creating documents: {filename}",
@ -1136,7 +1056,6 @@ async def process_file_in_background(
"documents_count": len(markdown_documents),
},
)
return last_created_doc
else:
# All documents were duplicates (markdown_documents was not empty, but all returned None)
await task_logger.log_task_success(
@ -1149,18 +1068,8 @@ async def process_file_in_background(
"documents_count": len(markdown_documents),
},
)
return None
elif app_config.ETL_SERVICE == "DOCLING":
# Update notification: parsing stage
if notification:
await NotificationService.document_processing.notify_processing_progress(
session,
notification,
stage="parsing",
stage_message="Extracting content",
)
await task_logger.log_task_progress(
log_entry,
f"Processing file with Docling ETL: {filename}",
@ -1243,12 +1152,6 @@ async def process_file_in_background(
},
)
# Update notification: chunking stage
if notification:
await NotificationService.document_processing.notify_processing_progress(
session, notification, stage="chunking"
)
# Process the document using our Docling background task
doc_result = await add_received_file_document_using_docling(
session,
@ -1281,7 +1184,6 @@ async def process_file_in_background(
"pages_processed": final_page_count,
},
)
return doc_result
else:
await task_logger.log_task_success(
log_entry,
@ -1292,7 +1194,6 @@ async def process_file_in_background(
"etl_service": "DOCLING",
},
)
return None
except Exception as e:
await session.rollback()

View file

@ -11,26 +11,6 @@ cleanup() {
trap cleanup SIGTERM SIGINT
# Run database migrations with safeguards
echo "Running database migrations..."
# Wait for database to be ready (max 30 seconds)
for i in {1..30}; do
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
echo "Database is ready."
break
fi
echo "Waiting for database... ($i/30)"
sleep 1
done
# Run migrations with timeout (60 seconds max)
if timeout 60 alembic upgrade head 2>&1; then
echo "Migrations completed successfully."
else
echo "WARNING: Migration failed or timed out. Continuing anyway..."
echo "You may need to run migrations manually: alembic upgrade head"
fi
echo "Starting FastAPI Backend..."
python main.py &
backend_pid=$!

6035
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,5 @@
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
# Electric SQL
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
# Contact Form Vars - OPTIONAL
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronDown, ChevronUp, FileX, Loader2, Plus } from "lucide-react";
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@ -114,7 +114,7 @@ export function DocumentsTableShell({
{loading ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">{t("loading")}</p>
</div>
</div>

View file

@ -0,0 +1,44 @@
"use client";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface ProcessingIndicatorProps {
documentProcessorTasksCount: number;
}
export function ProcessingIndicator({ documentProcessorTasksCount }: ProcessingIndicatorProps) {
const t = useTranslations("documents");
// Only show when there are document_processor tasks (uploads), not connector_indexing_task (periodic reindexing)
if (documentProcessorTasksCount === 0) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: "auto", marginBottom: 24 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3 }}
>
<Alert className="border-border bg-primary/5">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
<div className="flex-1">
<AlertTitle className="text-primary font-semibold">
{t("processing_documents")}
</AlertTitle>
<AlertDescription className="text-muted-foreground">
{t("active_tasks_count", { count: documentProcessorTasksCount })}
</AlertDescription>
</div>
</div>
</Alert>
</motion.div>
</AnimatePresence>
);
}

View file

@ -6,18 +6,20 @@ import { RefreshCw, SquarePlus, Upload } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useLogsSummary } from "@/hooks/use-logs";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { DocumentsFilters } from "./components/DocumentsFilters";
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
import { PaginationControls } from "./components/PaginationControls";
import { ProcessingIndicator } from "./components/ProcessingIndicator";
import type { ColumnVisibility } from "./components/types";
function useDebounced<T>(value: T, delay = 250) {
@ -107,52 +109,6 @@ export default function DocumentsTable() {
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
});
// Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected)
const showSurfsenseDocs =
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
// Use query for fetching SurfSense docs
const {
data: surfsenseDocsResponse,
isLoading: isSurfsenseDocsLoading,
refetch: refetchSurfsenseDocs,
} = useQuery({
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
queryFn: () =>
documentsApiService.getSurfsenseDocs({
queryParams: {
page: pageIndex,
page_size: pageSize,
title: debouncedSearch.trim() || undefined,
},
}),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: showSurfsenseDocs,
});
// Transform SurfSense docs to match the Document type
const surfsenseDocsAsDocuments: Document[] = useMemo(() => {
if (!surfsenseDocsResponse?.items) return [];
return surfsenseDocsResponse.items.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: "SURFSENSE_DOCS",
document_metadata: { source: doc.source },
content: doc.content,
created_at: new Date().toISOString(),
search_space_id: -1, // Special value for global docs
}));
}, [surfsenseDocsResponse]);
// Merge type counts with SURFSENSE_DOCS count
const typeCounts = useMemo(() => {
const counts = { ...(rawTypeCounts || {}) };
if (surfsenseDocsResponse?.total) {
counts.SURFSENSE_DOCS = surfsenseDocsResponse.total;
}
return counts;
}, [rawTypeCounts, surfsenseDocsResponse?.total]);
// Extract documents and total based on search state
const documents = debouncedSearch.trim()
? searchResponse?.items || []
@ -194,6 +150,30 @@ export default function DocumentsTable() {
}
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
// Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, {
enablePolling: true,
refetchInterval: 5000, // Poll every 5 seconds when tasks are active
});
// Filter active tasks to only include document_processor tasks (uploads via "add sources")
// Exclude connector_indexing_task tasks (periodic reindexing)
const documentProcessorTasks =
summary?.active_tasks.filter((task) => task.source === "document_processor") || [];
const documentProcessorTasksCount = documentProcessorTasks.length;
const activeTasksCount = summary?.active_tasks.length || 0;
const prevActiveTasksCount = useRef(activeTasksCount);
// Auto-refresh when a task finishes
useEffect(() => {
if (prevActiveTasksCount.current > activeTasksCount) {
// A task has finished!
refreshCurrentView();
}
prevActiveTasksCount.current = activeTasksCount;
}, [activeTasksCount, refreshCurrentView]);
// Create a delete function for single document deletion
const deleteDocument = useCallback(
async (id: number) => {
@ -282,6 +262,8 @@ export default function DocumentsTable() {
</div>
</motion.div>
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
<DocumentsFilters
typeCounts={rawTypeCounts ?? {}}
selectedIds={selectedIds}

View file

@ -438,7 +438,9 @@ export default function EditorPage() {
{saving ? (
<>
<Loader2 className="h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
<span className="text-xs md:text-sm">
{isNewNote ? "Creating..." : "Saving..."}
</span>
</>
) : (
<>

View file

@ -1294,7 +1294,7 @@ function CreateInviteDialog({
{creating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating
Creating...
</>
) : (
"Create Invite"
@ -1471,7 +1471,7 @@ function CreateRoleDialog({
{creating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating
Creating...
</>
) : (
"Create Role"

View file

@ -2,7 +2,6 @@ import type { Metadata } from "next";
import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
import { ElectricProvider } from "@/components/providers/ElectricProvider";
import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider";
import { ThemeProvider } from "@/components/theme/theme-provider";
@ -103,9 +102,7 @@ export default function RootLayout({
defaultTheme="light"
>
<RootProvider>
<ReactQueryClientProvider>
<ElectricProvider>{children}</ElectricProvider>
</ReactQueryClientProvider>
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
<Toaster />
</RootProvider>
</ThemeProvider>

View file

@ -13,7 +13,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
export function UserDropdown({
@ -27,20 +26,12 @@ export function UserDropdown({
}) {
const router = useRouter();
const handleLogout = async () => {
const handleLogout = () => {
try {
// Track logout event and reset PostHog identity
trackLogout();
resetUser();
// Best-effort cleanup of Electric SQL / PGlite
// Even if this fails, login-time cleanup will handle it
try {
await cleanupElectric();
} catch (err) {
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
}
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
window.location.href = "/";
@ -49,7 +40,7 @@ export function UserDropdown({
console.error("Error during logout:", error);
// Optionally, provide user feedback
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
alert("Logout failed. Please try again.");
window.location.href = "/";
}
}

View file

@ -357,7 +357,7 @@ export const ComposerAddAttachment: FC = () => {
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
<Upload className="size-4" />
<span>Upload Documents</span>
<span>Upload Files</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -0,0 +1,300 @@
import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import {
AlertCircle,
ArrowUpIcon,
ChevronRightIcon,
Loader2,
Plug2,
Plus,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ComposerAddAttachment } from "@/components/assistant-ui/attachment";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
import { cn } from "@/lib/utils";
const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(
false,
searchSpaceId ? Number(searchSpaceId) : undefined
);
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
const [isOpen, setIsOpen] = useState(false);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isLoading = connectorsLoading || documentTypesLoading;
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
: [];
// Count only active connectors (matching what's shown in the Active tab)
const activeConnectorsCount = connectors.length;
const hasConnectors = activeConnectorsCount > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const handleMouseEnter = useCallback(() => {
// Clear any pending close timeout
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setIsOpen(true);
}, []);
const handleMouseLeave = useCallback(() => {
// Delay closing by 150ms for better UX
closeTimeoutRef.current = setTimeout(() => {
setIsOpen(false);
}, 150);
}, []);
if (!searchSpaceId) return null;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
"outline-none focus:outline-none focus-visible:outline-none",
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none",
"data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0",
"text-muted-foreground"
)}
aria-label={
hasConnectors
? `View ${activeConnectorsCount} active connectors`
: "Add your first connector"
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Plug2 className="size-4" />
{activeConnectorsCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
</span>
)}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-64 p-3"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{hasSources ? (
<div className="space-y-3">
{activeConnectorsCount > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">Active Connectors</p>
<span className="text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{activeConnectorsCount}
</span>
</div>
)}
{activeConnectorsCount > 0 && (
<div className="flex flex-wrap gap-2">
{connectors.map((connector) => (
<div
key={`connector-${connector.id}`}
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
>
{getConnectorIcon(connector.connector_type, "size-3.5")}
<span className="truncate max-w-[100px]">{connector.name}</span>
</div>
))}
</div>
)}
{activeDocumentTypes.length > 0 && (
<>
{activeConnectorsCount > 0 && (
<div className="pt-2 border-t border-border/50">
<p className="text-xs font-medium text-muted-foreground mb-2">Documents</p>
</div>
)}
<div className="flex flex-wrap gap-2">
{activeDocumentTypes.map(([docType, count]) => (
<div
key={docType}
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
>
{getConnectorIcon(docType, "size-3.5")}
<span className="truncate max-w-[100px]">
{getDocumentTypeLabel(docType)}
</span>
<span className="flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-medium rounded-full bg-primary/10 text-primary">
{count > 999 ? "999+" : count}
</span>
</div>
))}
</div>
</>
)}
<div className="pt-1 border-t border-border/50">
<button
type="button"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
/* Connector popup should be opened via the connector indicator button */
}}
>
<Plus className="size-3" />
Add more sources
<ChevronRightIcon className="size-3" />
</button>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-sm font-medium">No sources yet</p>
<p className="text-xs text-muted-foreground">
Add documents or connect data sources to enhance search results.
</p>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1"
onClick={() => {
/* Connector popup should be opened via the connector indicator button */
}}
>
<Plus className="size-3" />
Add Connector
</button>
</div>
)}
</PopoverContent>
</Popover>
);
};
export const ComposerAction: FC = () => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const hasProcessingAttachments = useAssistantState(({ composer }) =>
composer.attachments?.some((att) => {
const status = att.status;
if (status?.type !== "running") return false;
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
})
);
// Check if composer text is empty
const isComposerEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || "";
return text.length === 0;
});
// Check if a model is configured
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences } = useAtomValue(llmPreferencesAtom);
const hasModelConfigured = useMemo(() => {
if (!preferences) return false;
const agentLlmId = preferences.agent_llm_id;
if (agentLlmId === null || agentLlmId === undefined) return false;
// Check if the configured model actually exists
if (agentLlmId < 0) {
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
}
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<ComposerAddAttachment />
<ConnectorIndicator />
</div>
{/* Show processing indicator when attachments are being processed */}
{hasProcessingAttachments && (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<Loader2 className="size-3 animate-spin" />
<span>Processing...</span>
</div>
)}
{/* Show warning when no model is configured */}
{!hasModelConfigured && !hasProcessingAttachments && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
<AlertCircle className="size-3" />
<span>Select a model</span>
</div>
)}
<AssistantIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
!hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
disabled={isSendDisabled}
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AssistantIf>
<AssistantIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AssistantIf>
</div>
);
};

View file

@ -0,0 +1,257 @@
import { ComposerPrimitive, useAssistantState, useComposerRuntime } from "@assistant-ui/react";
import { useAtom, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ComposerAction } from "@/components/assistant-ui/composer-action";
import {
InlineMentionEditor,
type InlineMentionEditorRef,
} from "@/components/assistant-ui/inline-mention-editor";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
import type { Document } from "@/contracts/types/document.types";
export const Composer: FC = () => {
// ---- State for document mentions (using atoms to persist across remounts) ----
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
// Check if thread is empty (new chat)
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
// Check if thread is currently running (streaming response)
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Auto-focus editor when on new chat page
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
// Small delay to ensure the editor is fully mounted
const timeoutId = setTimeout(() => {
editorRef.current?.focus();
hasAutoFocusedRef.current = true;
}, 100);
return () => clearTimeout(timeoutId);
}
}, [isThreadEmpty]);
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer
const handleEditorChange = useCallback(
(text: string) => {
composerRuntime.setText(text);
},
[composerRuntime]
);
// Handle @ mention trigger from inline editor
const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true);
setMentionQuery(query);
}, []);
// Handle mention close
const handleMentionClose = useCallback(() => {
if (showDocumentPopover) {
setShowDocumentPopover(false);
setMentionQuery("");
}
}, [showDocumentPopover]);
// Handle keyboard navigation when popover is open
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showDocumentPopover) {
if (e.key === "ArrowDown") {
e.preventDefault();
documentPickerRef.current?.moveDown();
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
documentPickerRef.current?.moveUp();
return;
}
if (e.key === "Enter") {
e.preventDefault();
documentPickerRef.current?.selectHighlighted();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setShowDocumentPopover(false);
setMentionQuery("");
return;
}
}
},
[showDocumentPopover]
);
// Handle submit from inline editor (Enter key)
const handleSubmit = useCallback(() => {
// Prevent sending while a response is still streaming
if (isThreadRunning) {
return;
}
if (!showDocumentPopover) {
composerRuntime.send();
// Clear the editor after sending
editorRef.current?.clear();
setMentionedDocuments([]);
setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
}
}, [
showDocumentPopover,
isThreadRunning,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
]);
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
},
[setMentionedDocuments, setMentionedDocumentIds]
);
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
for (const doc of newDocs) {
editorRef.current?.insertDocumentChip(doc);
}
setMentionedDocuments((prev) => {
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
);
const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
setMentionQuery("");
},
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
{/* -------- Inline Mention Editor -------- */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
placeholder="Ask SurfSense or @mention docs"
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onChange={handleEditorChange}
onDocumentRemove={handleDocumentRemove}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
className="min-h-[24px]"
/>
</div>
{/* -------- Document mention popover (rendered via portal) -------- */}
{showDocumentPopover &&
typeof document !== "undefined" &&
createPortal(
<>
{/* Backdrop */}
<button
type="button"
className="fixed inset-0 cursor-default"
style={{ zIndex: 9998 }}
onClick={() => setShowDocumentPopover(false)}
aria-label="Close document picker"
/>
{/* Popover positioned above input */}
<div
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
style={{
zIndex: 9999,
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}}
>
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
</>,
document.body
)}
<ComposerAction />
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};

View file

@ -1,16 +1,19 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Cable, Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import type { FC } from "react";
import { type FC, useEffect, useMemo } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
import { useLogsSummary } from "@/hooks/use-logs";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
@ -18,7 +21,6 @@ import { ConnectorEditView } from "./connector-popup/connector-configs/views/con
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
@ -27,13 +29,18 @@ import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view
export const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams();
// Fetch document type counts using Electric SQL + PGlite for real-time updates
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
// Check if YouTube view is active
const isYouTubeView = searchParams.get("view") === "youtube";
// Track active indexing tasks
const { summary: logsSummary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
enablePolling: true,
refetchInterval: 5000,
});
// Use the custom hook for dialog state management
const {
isOpen,
@ -86,35 +93,57 @@ export const ConnectorIndicator: FC = () => {
setConnectorName,
} = useConnectorDialog();
// Fetch connectors using Electric SQL + PGlite for real-time updates
// This provides instant updates when connectors change, without polling
// Fetch connectors using React Query with conditional refetchInterval
// This automatically refetches when mutations invalidate the cache (event-driven)
// and also polls when dialog is open to catch external changes
const {
connectors: connectorsFromElectric = [],
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsElectric,
} = useConnectorsElectric(searchSpaceId);
data: connectors = [],
isLoading: connectorsLoading,
refetch: refreshConnectors,
} = useQuery({
queryKey: cacheKeys.connectors.all(searchSpaceId || ""),
queryFn: () =>
connectorsApiService.getConnectors({
queryParams: {
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
},
}),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes (same as connectorsAtom)
// Poll when dialog is open to catch external changes
refetchInterval: isOpen ? 5000 : false, // 5 seconds when open, no polling when closed
});
// Fallback to API if Electric fails or is not available
const connectors =
connectorsFromElectric.length > 0 || !connectorsError
? connectorsFromElectric
: allConnectors || [];
const queryClient = useQueryClient();
// Manual refresh function that works with both Electric and API
const refreshConnectors = async () => {
if (connectorsFromElectric.length > 0 || !connectorsError) {
await refreshConnectorsElectric();
} else {
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
// The connectorsAtom will handle refetching if needed
}
};
// Also refresh document type counts when dialog is open
useEffect(() => {
if (!isOpen || !searchSpaceId) return;
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[]
);
const POLL_INTERVAL = 5000; // 5 seconds, same as connectors
const intervalId = setInterval(() => {
// Invalidate document type counts to refresh active document types
queryClient.invalidateQueries({
queryKey: cacheKeys.documents.typeCounts(searchSpaceId),
});
}, POLL_INTERVAL);
// Cleanup interval on unmount or when dialog closes
return () => {
clearInterval(intervalId);
};
}, [isOpen, searchSpaceId, queryClient]);
// Get connector IDs that are currently being indexed
const indexingConnectorIds = useMemo(() => {
if (!logsSummary?.active_tasks) return new Set<number>();
return new Set(
logsSummary.active_tasks
.filter((task) => task.source?.includes("connector_indexing") && task.connector_id != null)
.map((task) => task.connector_id as number)
);
}, [logsSummary?.active_tasks]);
const isLoading = connectorsLoading || documentTypesLoading;
@ -129,9 +158,8 @@ export const ConnectorIndicator: FC = () => {
const activeConnectorsCount = connectors.length; // Only actual connectors, not document types
// Check which connectors are already connected
// Using Electric SQL + PGlite for real-time connector updates
const connectedTypes = new Set(
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
(allConnectors || []).map((c: SearchSourceConnector) => c.connector_type)
);
if (!searchSpaceId) return null;
@ -175,8 +203,9 @@ export const ConnectorIndicator: FC = () => {
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]} // Using Electric SQL + PGlite for real-time connector updates (all connector types)
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
@ -192,7 +221,7 @@ export const ConnectorIndicator: FC = () => {
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onSubmit={handleSubmitConnectForm}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
@ -214,18 +243,13 @@ export const ConnectorIndicator: FC = () => {
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onSave={() => handleSaveConnector(() => refreshConnectors())}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
}
? () =>
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type)
: undefined
}
onConfigChange={setConnectorConfig}
@ -252,12 +276,7 @@ export const ConnectorIndicator: FC = () => {
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onStartIndexing={() => handleStartIndexing(() => refreshConnectors())}
onSkip={handleSkipIndexing}
/>
) : (
@ -286,9 +305,10 @@ export const ConnectorIndicator: FC = () => {
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
allConnectors={allConnectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
@ -305,6 +325,7 @@ export const ConnectorIndicator: FC = () => {
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}

View file

@ -1,10 +1,12 @@
"use client";
import { IconBrandYoutube } from "@tabler/icons-react";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { FileText, Loader2 } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { LogActiveTask } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { ConnectorStatusBadge } from "./connector-status-badge";
@ -18,11 +20,24 @@ interface ConnectorCardProps {
isConnecting?: boolean;
documentCount?: number;
accountCount?: number;
lastIndexedAt?: string | null;
isIndexing?: boolean;
activeTask?: LogActiveTask;
onConnect?: () => void;
onManage?: () => void;
}
/**
* Extract a number from the active task message for display
* Looks for patterns like "45 indexed", "Processing 123", etc.
*/
function extractIndexedCount(message: string | undefined): number | null {
if (!message) return null;
// Try to find a number in the message
const match = message.match(/(\d+)/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Format document count (e.g., "1.2k docs", "500 docs", "1.5M docs")
*/
@ -37,6 +52,45 @@ function formatDocumentCount(count: number | undefined): string {
return `${m.replace(/\.0$/, "")}M docs`;
}
/**
* Format last indexed date with contextual messages
* Examples: "Just now", "10 minutes ago", "Today at 2:30 PM", "Yesterday at 3:45 PM", "3 days ago", "Jan 15, 2026"
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
return format(date, "MMM d, yyyy");
}
export const ConnectorCard: FC<ConnectorCardProps> = ({
id,
title,
@ -46,7 +100,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
isConnecting = false,
documentCount,
accountCount,
lastIndexedAt,
isIndexing = false,
activeTask,
onConnect,
onManage,
}) => {
@ -59,11 +115,36 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
const statusMessage = getConnectorStatusMessage(connectorType);
const showWarnings = shouldShowWarnings();
// Extract count from active task message during indexing
const indexingCount = extractIndexedCount(activeTask?.message);
// Determine the status content to display
const getStatusContent = () => {
if (isIndexing) {
return (
<div className="flex items-center gap-2 w-full max-w-[200px]">
<span className="text-[11px] text-primary font-medium whitespace-nowrap">
{indexingCount !== null ? <>{indexingCount.toLocaleString()} indexed</> : "Syncing..."}
</span>
{/* Indeterminate progress bar with animation */}
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-primary/20">
<div className="absolute h-full bg-primary rounded-full animate-progress-indeterminate" />
</div>
</div>
);
}
if (isConnected) {
// Don't show last indexed in overview tabs - only show in accounts list view
return null;
// Show last indexed date for connected connectors
if (lastIndexedAt) {
return (
<span className="whitespace-nowrap text-[10px]">
Last indexed: {formatLastIndexedDate(lastIndexedAt)}
</span>
);
}
// Fallback for connected but never indexed
return <span className="whitespace-nowrap text-[10px]">Never indexed</span>;
}
return description;
@ -105,13 +186,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
/>
)}
</div>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Syncing
</p>
) : isConnected ? (
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
{accountCount !== undefined && accountCount > 0 && (
<>
@ -122,8 +199,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</>
)}
</p>
) : (
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
)}
</div>
<Button

View file

@ -44,7 +44,7 @@ export const ConnectorStatusBadge: FC<ConnectorStatusBadgeProps> = ({
case "deprecated":
return {
icon: AlertTriangle,
className: "text-slate-500 dark:text-slate-400",
className: "ext-slate-500 dark:text-slate-400",
defaultTitle: "Deprecated",
};
default:

View file

@ -135,7 +135,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting
Connecting...
</>
) : (
<>Connect {getConnectorTypeDisplay(connectorType)}</>

View file

@ -170,7 +170,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{isQuickIndexing || isIndexing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Syncing
Indexing...
</>
) : (
<>
@ -277,7 +277,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Disconnecting
Disconnecting...
</>
) : (
"Confirm Disconnect"

View file

@ -491,23 +491,20 @@ export const useConnectorDialog = () => {
// Handle submitting connect form
const handleSubmitConnectForm = useCallback(
async (
formData: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null;
next_scheduled_at: null;
startDate?: Date;
endDate?: Date;
periodicEnabled?: boolean;
frequencyMinutes?: string;
},
onIndexingStart?: (connectorId: number) => void
) => {
async (formData: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null;
next_scheduled_at: null;
startDate?: Date;
endDate?: Date;
periodicEnabled?: boolean;
frequencyMinutes?: string;
}) => {
if (!searchSpaceId || !connectingConnectorType) return;
// Prevent multiple submissions using ref for immediate check
@ -606,11 +603,6 @@ export const useConnectorDialog = () => {
});
}
// Notify caller that indexing is starting (for UI syncing state)
if (onIndexingStart) {
onIndexingStart(connector.id);
}
// Start indexing (backend will use defaults if dates are undefined)
const startDateStr = startDateForIndexing
? format(startDateForIndexing, "yyyy-MM-dd")

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import {
type ConnectorStatusConfig,
connectorStatusConfig,
@ -14,43 +14,34 @@ export function useConnectorStatus() {
/**
* Get status configuration for a specific connector type
*/
const getConnectorStatus = useCallback(
(connectorType: string | undefined): ConnectorStatusConfig => {
if (!connectorType) {
return getDefaultConnectorStatus();
}
const getConnectorStatus = (connectorType: string | undefined): ConnectorStatusConfig => {
if (!connectorType) {
return getDefaultConnectorStatus();
}
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
},
[]
);
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
};
/**
* Check if a connector is enabled
*/
const isConnectorEnabled = useCallback(
(connectorType: string | undefined): boolean => {
return getConnectorStatus(connectorType).enabled;
},
[getConnectorStatus]
);
const isConnectorEnabled = (connectorType: string | undefined): boolean => {
return getConnectorStatus(connectorType).enabled;
};
/**
* Get status message for a connector
*/
const getConnectorStatusMessage = useCallback(
(connectorType: string | undefined): string | null => {
return getConnectorStatus(connectorType).statusMessage || null;
},
[getConnectorStatus]
);
const getConnectorStatusMessage = (connectorType: string | undefined): string | null => {
return getConnectorStatus(connectorType).statusMessage || null;
};
/**
* Check if warnings should be shown globally
*/
const shouldShowWarnings = useCallback((): boolean => {
const shouldShowWarnings = (): boolean => {
return connectorStatusConfig.globalSettings.showWarnings;
}, []);
};
return useMemo(
() => ({
@ -59,6 +50,6 @@ export function useConnectorStatus() {
getConnectorStatusMessage,
shouldShowWarnings,
}),
[getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings]
[]
);
}

View file

@ -1,81 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/**
* Hook to track which connectors are currently indexing using local state.
*
* This provides a better UX than polling by:
* 1. Setting indexing state immediately when user triggers indexing (optimistic)
* 2. Clearing indexing state when Electric SQL detects last_indexed_at changed
*
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
*/
export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
// Set of connector IDs that are currently indexing
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
// Track previous last_indexed_at values to detect changes
const previousLastIndexedAtRef = useRef<Map<number, string | null>>(new Map());
// Detect when last_indexed_at changes (indexing completed) via Electric SQL
useEffect(() => {
const previousValues = previousLastIndexedAtRef.current;
const newIndexingIds = new Set(indexingConnectorIds);
let hasChanges = false;
for (const connector of connectors) {
const previousValue = previousValues.get(connector.id);
const currentValue = connector.last_indexed_at;
// If last_indexed_at changed and connector was in indexing state, clear it
if (
previousValue !== undefined && // We've seen this connector before
previousValue !== currentValue && // Value changed
indexingConnectorIds.has(connector.id) // It was marked as indexing
) {
newIndexingIds.delete(connector.id);
hasChanges = true;
}
// Update previous value tracking
previousValues.set(connector.id, currentValue);
}
if (hasChanges) {
setIndexingConnectorIds(newIndexingIds);
}
}, [connectors, indexingConnectorIds]);
// Add a connector to the indexing set (called when indexing starts)
const startIndexing = useCallback((connectorId: number) => {
setIndexingConnectorIds((prev) => {
const next = new Set(prev);
next.add(connectorId);
return next;
});
}, []);
// Remove a connector from the indexing set (called manually if needed)
const stopIndexing = useCallback((connectorId: number) => {
setIndexingConnectorIds((prev) => {
const next = new Set(prev);
next.delete(connectorId);
return next;
});
}, []);
// Check if a connector is currently indexing
const isIndexing = useCallback(
(connectorId: number) => indexingConnectorIds.has(connectorId),
[indexingConnectorIds]
);
return {
indexingConnectorIds,
startIndexing,
stopIndexing,
isIndexing,
};
}

View file

@ -1,5 +1,6 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import type { FC } from "react";
@ -8,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -19,6 +21,7 @@ interface ActiveConnectorsTabProps {
activeDocumentTypes: Array<[string, number]>;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
searchSpaceId: string;
onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void;
@ -31,6 +34,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
activeDocumentTypes,
connectors,
indexingConnectorIds,
logsSummary,
searchSpaceId,
onTabChange,
onManage,
@ -63,6 +67,32 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
return `${m.replace(/\.0$/, "")}M docs`;
};
// Format last indexed date with contextual messages
const formatLastIndexedDate = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
if (minutesAgo < 1) return "Just now";
if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
if (isToday(date)) return `Today at ${format(date, "h:mm a")}`;
if (isYesterday(date)) return `Yesterday at ${format(date, "h:mm a")}`;
if (daysAgo < 7) return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
return format(date, "MMM d, yyyy");
};
// Get most recent last indexed date from a list of connectors
const getMostRecentLastIndexed = (
connectorsList: SearchSourceConnector[]
): string | undefined => {
return connectorsList.reduce<string | undefined>((latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest;
}, undefined);
};
// Document types that should be shown as standalone cards (not from connectors)
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
@ -160,6 +190,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
documentTypeCounts
);
const accountCount = typeConnectors.length;
const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
const handleManageClick = () => {
if (onViewAccountsList) {
@ -173,10 +204,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div
key={`oauth-type-${connectorType}`}
className={cn(
"relative flex items-center gap-4 p-4 rounded-xl transition-all",
"relative flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isAnyIndexing
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
@ -194,17 +225,22 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Syncing
Indexing...
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{mostRecentLastIndexed
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
: "Never indexed"}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</p>
</div>
<Button
variant="secondary"
@ -221,6 +257,9 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{/* Non-OAuth Connectors - Individual Cards */}
{filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
const documentCount = getDocumentCountForConnector(
connector.connector_type,
documentTypeCounts
@ -230,10 +269,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div
key={`connector-${connector.id}`}
className={cn(
"flex items-center gap-4 p-4 rounded-xl transition-all",
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isIndexing
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
@ -253,13 +292,23 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Syncing
Indexing...
{activeTask?.message && (
<span className="text-muted-foreground truncate max-w-[150px]">
{activeTask.message}
</span>
)}
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1">
{formatDocumentCount(documentCount)}
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
</div>
<Button
variant="secondary"
@ -313,12 +362,19 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
) : (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<Cable className="size-8 text-muted-foreground" />
<Cable className="size-8 text-muted-foreground/50" />
</div>
<h4 className="text-lg font-semibold">No active sources</h4>
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">
Connect your first service to start searching across all your data.
</p>
<Button
variant="link"
className="mt-6 text-primary hover:underline"
onClick={() => onTabChange("all")}
>
Browse available connectors
</Button>
</div>
)}
</TabsContent>

View file

@ -1,7 +1,10 @@
"use client";
import { Plus } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { ConnectorCard } from "../components/connector-card";
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -27,6 +30,7 @@ interface AllConnectorsTabProps {
allConnectors: SearchSourceConnector[] | undefined;
documentTypeCounts?: Record<string, number>;
indexingConnectorIds?: Set<number>;
logsSummary?: LogSummary;
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
onConnectNonOAuth?: (connectorType: string) => void;
onCreateWebcrawler?: () => void;
@ -37,11 +41,13 @@ interface AllConnectorsTabProps {
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
searchQuery,
searchSpaceId,
connectedTypes,
connectingId,
allConnectors,
documentTypeCounts,
indexingConnectorIds,
logsSummary,
onConnectOAuth,
onConnectNonOAuth,
onCreateWebcrawler,
@ -49,6 +55,14 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Helper to find active task for a connector
const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => {
if (!logsSummary?.active_tasks) return undefined;
return logsSummary.active_tasks.find(
(task: LogActiveTask) => task.connector_id === connectorId
);
};
// Filter connectors based on search
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) =>
@ -89,6 +103,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
)
: [];
// Get the most recent last_indexed_at across all accounts
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
(latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest)
? c.last_indexed_at
: latest;
},
undefined
);
const documentCount = getDocumentCountForConnector(
connector.connectorType,
documentTypeCounts
@ -97,6 +123,11 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
// Check if any account is currently indexing
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
// Get active task from any indexing account
const activeTask = typeConnectors
.map((c) => getActiveTaskForConnector(c.id))
.find((task) => task !== undefined);
return (
<ConnectorCard
key={connector.id}
@ -108,7 +139,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnecting={isConnecting}
documentCount={documentCount}
accountCount={typeConnectors.length}
lastIndexedAt={mostRecentLastIndexed}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
@ -146,6 +179,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentTypeCounts
);
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector
? getActiveTaskForConnector(actualConnector.id)
: undefined;
const handleConnect = onConnectNonOAuth
? () => onConnectNonOAuth(connector.connectorType)
@ -161,7 +197,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
lastIndexedAt={actualConnector?.last_indexed_at}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={handleConnect}
onManage={
actualConnector && onManage ? () => onManage(actualConnector) : undefined
@ -202,6 +240,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
? getDocumentCountForConnector(crawler.connectorType, documentTypeCounts)
: undefined;
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector
? getActiveTaskForConnector(actualConnector.id)
: undefined;
const handleConnect =
isYouTube && onCreateYouTubeCrawler
@ -226,7 +267,9 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
lastIndexedAt={actualConnector?.last_indexed_at}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={handleConnect}
onManage={
actualConnector && onManage ? () => onManage(actualConnector) : undefined

View file

@ -6,6 +6,7 @@ import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
@ -15,6 +16,7 @@ interface ConnectorAccountsListViewProps {
connectorTitle: string;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void;
@ -58,6 +60,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectorTitle,
connectors,
indexingConnectorIds,
logsSummary,
onBack,
onManage,
onAddAccount,
@ -122,7 +125,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
)}
</div>
<span className="text-[11px] sm:text-[12px] font-medium">
{isConnecting ? "Connecting" : "Add Account"}
{isConnecting ? "Connecting..." : "Add Account"}
</span>
</button>
</div>
@ -134,15 +137,18 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
return (
<div
key={connector.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl transition-all",
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isIndexing
? "bg-primary/5 border-0"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
@ -162,7 +168,12 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Syncing
Indexing...
{activeTask?.message && (
<span className="text-muted-foreground truncate max-w-[100px]">
{activeTask.message}
</span>
)}
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">

View file

@ -2,6 +2,7 @@
import { useAtomValue } from "jotai";
import { Upload } from "lucide-react";
import { useRouter } from "next/navigation";
import {
createContext,
type FC,
@ -84,11 +85,13 @@ const DocumentUploadPopupContent: FC<{
onOpenChange: (open: boolean) => void;
}> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const router = useRouter();
if (!searchSpaceId) return null;
const handleSuccess = () => {
onOpenChange(false);
router.push(`/dashboard/${searchSpaceId}/documents`);
};
return (

View file

@ -0,0 +1,71 @@
import { useAtomValue } from "jotai";
import type { FC } from "react";
import { useMemo } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Composer } from "@/components/assistant-ui/composer";
const getTimeBasedGreeting = (userEmail?: string): string => {
const hour = new Date().getHours();
// Extract first name from email if available
const firstName = userEmail
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
userEmail.split("@")[0].split(".")[0].slice(1)
: null;
// Array of greeting variations for each time period
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
// Select a random greeting based on time
let greeting: string;
if (hour < 5) {
// Late night: midnight to 5 AM
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
} else if (hour < 12) {
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
} else if (hour < 18) {
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
} else if (hour < 22) {
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
} else {
// Night: 10 PM to midnight
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
}
// Add personalization with first name if available
if (firstName) {
return `${greeting}, ${firstName}!`;
}
return `${greeting}!`;
};
export const ThreadWelcome: FC = () => {
const { data: user } = useAtomValue(currentUserAtom);
// Memoize greeting so it doesn't change on re-renders (only on user change)
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
{/* Greeting positioned above the composer - fixed position */}
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-3xl md:text-5xl delay-100 duration-500 ease-out fill-mode-both">
{greeting}
</h1>
</div>
{/* Composer - top edge fixed, expands downward only */}
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<Composer />
</div>
</div>
);
};

View file

@ -124,23 +124,14 @@ const ThreadScrollToBottom: FC = () => {
);
};
const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => {
const getTimeBasedGreeting = (userEmail?: string): string => {
const hour = new Date().getHours();
// Extract first name: prefer display_name, fall back to email extraction
let firstName: string | null = null;
if (user?.display_name?.trim()) {
// Use display_name if available and not empty
// Extract first name from display_name (take first word)
const nameParts = user.display_name.trim().split(/\s+/);
firstName = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1).toLowerCase();
} else if (user?.email) {
// Fall back to email extraction if display_name is not available
firstName =
user.email.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
user.email.split("@")[0].split(".")[0].slice(1);
}
// Extract first name from email if available
const firstName = userEmail
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
userEmail.split("@")[0].split(".")[0].slice(1)
: null;
// Array of greeting variations for each time period
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
@ -181,7 +172,7 @@ const ThreadWelcome: FC = () => {
const { data: user } = useAtomValue(currentUserAtom);
// Memoize greeting so it doesn't change on re-renders (only on user change)
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">

View file

@ -27,7 +27,12 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
<span className="aui-sr-only sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
<TooltipContent
side={side}
className="bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none"
>
{tooltip}
</TooltipContent>
</Tooltip>
);
}

View file

@ -184,7 +184,7 @@ function GetStartedButton() {
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/register"
href="/login"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
>
Get Started

View file

@ -21,7 +21,6 @@ import {
} from "@/components/ui/dialog";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
@ -279,19 +278,10 @@ export function LayoutDataProvider({
router.push(`/dashboard/${searchSpaceId}/team`);
}, [router, searchSpaceId]);
const handleLogout = useCallback(async () => {
const handleLogout = useCallback(() => {
try {
trackLogout();
resetUser();
// Best-effort cleanup of Electric SQL / PGlite
// Even if this fails, login-time cleanup will handle it
try {
await cleanupElectric();
} catch (err) {
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
}
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");

View file

@ -3,7 +3,6 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { NotificationButton } from "@/components/notifications/NotificationButton";
interface HeaderProps {
breadcrumb?: React.ReactNode;
@ -30,9 +29,6 @@ export function Header({
{/* Right side - Actions */}
<div className="flex items-center gap-2">
{/* Notifications */}
<NotificationButton />
{/* Theme toggle */}
{onToggleTheme && (
<Tooltip>

View file

@ -34,6 +34,7 @@ import {
deleteThread,
fetchThreads,
searchThreads,
type ThreadListItem,
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
@ -409,7 +410,7 @@ export function AllPrivateChatsSidebar({
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
@ -419,7 +420,7 @@ export function AllPrivateChatsSidebar({
</div>
) : (
<div className="text-center py-8">
<Lock className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<Lock className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"

View file

@ -34,6 +34,7 @@ import {
deleteThread,
fetchThreads,
searchThreads,
type ThreadListItem,
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
@ -409,7 +410,7 @@ export function AllSharedChatsSidebar({
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
@ -419,7 +420,7 @@ export function AllSharedChatsSidebar({
</div>
) : (
<div className="text-center py-8">
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<Users className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"

View file

@ -156,10 +156,10 @@ export function Sidebar({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
className="h-5 w-5"
onClick={onViewAllSharedChats}
>
<FolderOpen className="h-4 w-4" />
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
@ -197,10 +197,10 @@ export function Sidebar({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
className="h-5 w-5"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-4 w-4" />
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">

View file

@ -37,14 +37,14 @@ export function SidebarSection({
{/* Action button - visible on hover (always visible on mobile) */}
{action && (
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center gap-0.5">
{action}
</div>
)}
{/* Persistent action - always visible */}
{persistentAction && (
<div className="shrink-0 pr-1 flex items-center">{persistentAction}</div>
<div className="shrink-0 pr-1 flex items-center gap-0.5">{persistentAction}</div>
)}
</div>

View file

@ -1,7 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Lock, Share2, Users } from "lucide-react";
import { Globe, Loader2, Lock, Share2, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -92,7 +92,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
variant="ghost"
size="sm"
className={cn(
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
"h-7 md:h-9 gap-1.5 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl",
"border border-border/80 bg-background/50 backdrop-blur-sm",
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
"text-xs md:text-sm font-medium text-foreground",
"focus-visible:ring-0 focus-visible:ring-offset-0",
@ -103,6 +104,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<span className="hidden md:inline">
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
</span>
<Share2 className="size-3 md:size-3.5 text-muted-foreground" />
</Button>
</PopoverTrigger>
@ -111,13 +113,25 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
align="end"
sideOffset={8}
>
<div className="p-3 md:p-4 border-b border-border/30">
<div className="flex items-center gap-2">
<Share2 className="size-4 md:size-5 text-primary" />
<div>
<h4 className="text-sm font-semibold">Share Chat</h4>
<p className="text-xs text-muted-foreground">
Control who can access this conversation
</p>
</div>
</div>
</div>
<div className="p-1.5 space-y-1">
{/* Updating overlay */}
{isUpdating && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Updating</span>
<span>Updating...</span>
</div>
</div>
)}
@ -135,8 +149,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
className={cn(
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
isSelected && "bg-accent/80"
"focus:outline-none focus:ring-2 focus:ring-primary/20",
isSelected && "bg-accent/80 ring-1 ring-primary/20"
)}
>
<div
@ -171,6 +185,18 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
);
})}
</div>
{/* Info footer */}
<div className="p-3 bg-muted/30 border-t border-border/30 rounded-b-xl">
<div className="flex items-start gap-2">
<Globe className="size-3.5 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{currentVisibility === "PRIVATE"
? "This chat is private. Only you can view and interact with it."
: "This chat is shared. All search space members can view, continue the conversation, and delete it."}
</p>
</div>
</div>
</PopoverContent>
</Popover>
);

View file

@ -329,7 +329,7 @@ export const DocumentMentionPicker = forwardRef<
</div>
) : actualDocuments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
<FileText className="h-5 w-5 text-muted-foreground/55 mb-1" />
<FileText className="h-5 w-5 text-muted-foreground/50 mb-1" />
<p className="text-sm text-muted-foreground">No documents found</p>
</div>
) : (
@ -337,7 +337,7 @@ export const DocumentMentionPicker = forwardRef<
{/* SurfSense Documentation Section */}
{surfsenseDocsList.length > 0 && (
<>
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
SurfSense Docs
</div>
{surfsenseDocsList.map((doc) => {
@ -385,7 +385,7 @@ export const DocumentMentionPicker = forwardRef<
{/* User Documents Section */}
{userDocsList.length > 0 && (
<>
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
Your Documents
</div>
{userDocsList.map((doc) => {

View file

@ -1,61 +0,0 @@
"use client";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNotifications } from "@/hooks/use-notifications";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { NotificationPopup } from "./NotificationPopup";
import { cn } from "@/lib/utils";
import { useParams } from "next/navigation";
export function NotificationButton() {
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
const userId = user?.id ? String(user.id) : null;
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
userId,
searchSpaceId
);
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 relative">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span
className={cn(
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
unreadCount > 9 && "px-1"
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80 p-0">
<NotificationPopup
notifications={notifications}
unreadCount={unreadCount}
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
/>
</PopoverContent>
</Popover>
);
}

View file

@ -1,127 +0,0 @@
"use client";
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
interface NotificationPopupProps {
notifications: Notification[];
unreadCount: number;
loading: boolean;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
export function NotificationPopup({
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
}: NotificationPopupProps) {
const handleMarkAsRead = async (id: number) => {
await markAsRead(id);
};
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
const formatTime = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
} catch {
return "Recently";
}
};
const getStatusIcon = (notification: Notification) => {
const status = notification.metadata?.status as string | undefined;
switch (status) {
case "in_progress":
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <Bell className="h-4 w-4 text-muted-foreground" />;
}
};
return (
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Notifications</h3>
</div>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
<CheckCheck className="h-3.5 w-3.5 mr-0" />
Mark all read
</Button>
)}
</div>
{/* Notifications List */}
<ScrollArea className="h-[400px]">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="pt-0 pb-2">
{notifications.map((notification, index) => (
<div key={notification.id}>
<button
type="button"
onClick={() => !notification.read && handleMarkAsRead(notification.id)}
className={cn(
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
)}
>
<div className="flex items-start gap-3 overflow-hidden">
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-2 mb-1">
<p
className={cn(
"text-xs font-medium break-all",
!notification.read && "font-semibold"
)}
>
{notification.title}
</p>
</div>
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
{notification.message}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-muted-foreground">
{formatTime(notification.created_at)}
</span>
</div>
</div>
</div>
</button>
{index < notifications.length - 1 && <Separator />}
</div>
))}
</div>
)}
</ScrollArea>
</div>
);
}

View file

@ -1,132 +0,0 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
initElectric,
cleanupElectric,
isElectricInitialized,
type ElectricClient,
} from "@/lib/electric/client";
import { ElectricContext } from "@/lib/electric/context";
interface ElectricProviderProps {
children: React.ReactNode;
}
/**
* ElectricProvider initializes the Electric SQL client with user-specific PGlite database
* and provides it to children via context.
*
* KEY BEHAVIORS:
* 1. Single initialization point - only this provider creates the Electric client
* 2. Creates user-specific database (isolated per user)
* 3. Cleans up other users' databases on login
* 4. Re-initializes when user changes
* 5. Provides client via context - hooks should use useElectricClient()
*/
export function ElectricProvider({ children }: ElectricProviderProps) {
const [electricClient, setElectricClient] = useState<ElectricClient | null>(null);
const [error, setError] = useState<Error | null>(null);
const {
data: user,
isSuccess: isUserLoaded,
isError: isUserError,
} = useAtomValue(currentUserAtom);
const previousUserIdRef = useRef<string | null>(null);
const initializingRef = useRef(false);
useEffect(() => {
// Skip on server side
if (typeof window === "undefined") return;
// If no user is logged in, don't initialize Electric
// The app can still function without real-time sync for non-authenticated pages
if (!isUserLoaded || !user?.id) {
// If we had a previous user and now logged out, cleanup
if (previousUserIdRef.current && isElectricInitialized()) {
console.log("[ElectricProvider] User logged out, cleaning up...");
cleanupElectric().then(() => {
previousUserIdRef.current = null;
setElectricClient(null);
});
}
return;
}
const userId = String(user.id);
// If already initialized for THIS user, skip
if (electricClient && previousUserIdRef.current === userId) {
return;
}
// Prevent concurrent initialization attempts
if (initializingRef.current) {
return;
}
// User changed or first initialization
initializingRef.current = true;
let mounted = true;
async function init() {
try {
console.log(`[ElectricProvider] Initializing for user: ${userId}`);
// If different user was previously initialized, cleanup will happen inside initElectric
const client = await initElectric(userId);
if (mounted) {
previousUserIdRef.current = userId;
setElectricClient(client);
setError(null);
console.log(`[ElectricProvider] ✅ Ready for user: ${userId}`);
}
} catch (err) {
console.error("[ElectricProvider] Failed to initialize:", err);
if (mounted) {
setError(err instanceof Error ? err : new Error("Failed to initialize Electric SQL"));
// Set client to null so hooks know initialization failed
setElectricClient(null);
}
} finally {
if (mounted) {
initializingRef.current = false;
}
}
}
init();
return () => {
mounted = false;
};
}, [user?.id, isUserLoaded, electricClient]);
// For non-authenticated pages (like landing page), render immediately with null context
// Also render immediately if user query failed (e.g., token expired)
if (!isUserLoaded || !user?.id || isUserError) {
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// Show loading state while initializing for authenticated users
if (!electricClient && !error) {
return (
<ElectricContext.Provider value={null}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Initializing...</div>
</div>
</ElectricContext.Provider>
);
}
// If there's an error, still render but warn
if (error) {
console.warn("[ElectricProvider] Initialization failed, sync may not work:", error.message);
}
// Provide the Electric client to children
return <ElectricContext.Provider value={electricClient}>{children}</ElectricContext.Provider>;
}

View file

@ -560,7 +560,7 @@ export function LLMConfigForm({
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" />
{mode === "edit" ? "Updating..." : "Creating"}
{mode === "edit" ? "Updating..." : "Creating..."}
</>
) : (
<>

View file

@ -3,6 +3,7 @@
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
@ -120,6 +121,7 @@ export function DocumentUploadTab({
onAccordionStateChange,
}: DocumentUploadTabProps) {
const t = useTranslations("upload_documents");
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState<string>("");
@ -222,7 +224,7 @@ export function DocumentUploadTab({
setUploadProgress(100);
trackDocumentUploadSuccess(Number(searchSpaceId), files.length);
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.();
onSuccess?.() || router.push(`/dashboard/${searchSpaceId}/documents`);
},
onError: (error: unknown) => {
clearInterval(progressInterval);

View file

@ -98,7 +98,7 @@ function PodcastGeneratingState({ title }: { title: string }) {
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
<span className="text-sm">Generating podcast. This may take a few minutes</span>
<span className="text-sm">Generating podcast... This may take a few minutes</span>
</div>
<div className="mt-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10">

View file

@ -42,7 +42,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance",
"bg-popover text-popover-foreground fill-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}

View file

@ -19,5 +19,5 @@
"elasticsearch",
"bookstack"
],
"defaultOpen": false
"defaultOpen": true
}

View file

@ -1,254 +0,0 @@
---
title: Electric SQL
description: Setting up Electric SQL for real-time data synchronization in SurfSense
---
# Electric SQL
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
## What Does Electric SQL Do?
When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this:
1. Backend writes data to PostgreSQL
2. Electric SQL detects changes and streams them to the frontend
3. PGlite (running in your browser) receives and stores the data locally in IndexedDB
4. Your UI updates instantly without refreshing
This means:
- **Notifications appear instantly** - No need to refresh the page
- **Document indexing progress updates live** - Watch your documents get processed
- **Connector status syncs automatically** - See when connectors finish syncing
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
## Docker Setup
### All-in-One Quickstart
The simplest way to run SurfSense with Electric SQL is using the all-in-one Docker image. This bundles everything into a single container:
- PostgreSQL + pgvector (vector database)
- Redis (task queue)
- Electric SQL (real-time sync)
- Backend API
- Frontend
```bash
docker run -d \
-p 3000:3000 \
-p 8000:8000 \
-p 5133:5133 \
-v surfsense-data:/data \
--name surfsense \
ghcr.io/modsetter/surfsense:latest
```
**With custom Electric SQL credentials:**
```bash
docker run -d \
-p 3000:3000 \
-p 8000:8000 \
-p 5133:5133 \
-v surfsense-data:/data \
-e ELECTRIC_DB_USER=your_electric_user \
-e ELECTRIC_DB_PASSWORD=your_electric_password \
--name surfsense \
ghcr.io/modsetter/surfsense:latest
```
Access SurfSense at `http://localhost:3000`. Electric SQL is automatically configured and running on port 5133.
### Docker Compose
For more control over individual services, use Docker Compose.
**Quickstart (all-in-one image):**
```bash
docker compose -f docker-compose.quickstart.yml up -d
```
**Standard setup (separate services):**
The `docker-compose.yml` includes the Electric SQL service configuration:
```yaml
electric:
image: electricsql/electric:latest
ports:
- "${ELECTRIC_PORT:-5133}:3000"
environment:
- DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-surfsense}?sslmode=disable}
- ELECTRIC_INSECURE=true
- ELECTRIC_WRITE_TO_PG_MODE=direct
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/v1/health"]
interval: 10s
timeout: 5s
retries: 5
```
No additional configuration is required - Electric SQL is pre-configured to work with the Docker PostgreSQL instance.
## Manual Setup
Follow the steps below based on your PostgreSQL setup.
### Step 1: Configure Environment Variables
Add the following variables to your root `.env` file:
```bash
ELECTRIC_PORT=5133
POSTGRES_DB=surfsense
POSTGRES_HOST=db
ELECTRIC_DB_USER=electric
ELECTRIC_DB_PASSWORD=electric_password
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
```
> **Note:** Set `POSTGRES_HOST=db` for Docker PostgreSQL, or `POSTGRES_HOST=host.docker.internal` for local PostgreSQL.
---
### Option A: Using Docker PostgreSQL
If you're using the Docker-managed PostgreSQL instance, follow these steps:
**1. Start PostgreSQL and Electric SQL:**
```bash
docker-compose up -d db electric
```
**2. Run database migration:**
```bash
cd surfsense_backend
uv run alembic upgrade head
```
**3. Start the backend:**
```bash
uv run main.py
```
Electric SQL is now configured and connected to your Docker PostgreSQL database.
---
### Option B: Using Local PostgreSQL
If you're using a local PostgreSQL installation, follow these steps:
**1. Enable logical replication in PostgreSQL:**
Open your `postgresql.conf` file using vim (or your preferred editor):
```bash
# Common locations:
# macOS (Homebrew): /opt/homebrew/var/postgresql@15/postgresql.conf
# Linux: /etc/postgresql/15/main/postgresql.conf
# Windows: C:\Program Files\PostgreSQL\15\data\postgresql.conf
sudo vim /path/to/postgresql.conf
```
Add the following settings:
```ini
# Enable logical replication (required for Electric SQL)
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
```
After saving the changes (`:wq` in vim), restart your PostgreSQL server for the configuration to take effect.
**2. Update environment variable:**
In your root `.env` file, set:
```bash
POSTGRES_HOST=host.docker.internal
```
**3. Start Electric SQL:**
```bash
docker-compose up -d electric
```
**4. Run database migration:**
```bash
cd surfsense_backend
uv run alembic upgrade head
```
**5. Start the backend:**
```bash
uv run main.py
```
Electric SQL is now configured and connected to your local PostgreSQL database.
## Environment Variables Reference
| Variable | Location | Description | Default |
|----------|----------|-------------|---------|
| `ELECTRIC_PORT` | docker-compose | Port to expose Electric SQL | `5133` |
| `ELECTRIC_DB_USER` | Backend | Database user for Electric | `electric` |
| `ELECTRIC_DB_PASSWORD` | Backend | Database password for Electric | `electric_password` |
| `NEXT_PUBLIC_ELECTRIC_URL` | Frontend | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` |
## Verify Setup
To verify Electric SQL is running correctly:
```bash
curl http://localhost:5133/v1/health
```
You should receive a healthy response.
## Troubleshooting
### Electric SQL Server Not Starting
**Check PostgreSQL settings:**
- Ensure `wal_level = logical` is set
- Verify the Electric user has replication permissions
- Check database connectivity from Electric container
### Real-time Updates Not Working
1. Open browser DevTools → Console
2. Look for errors containing `[Electric]`
3. Check Network tab for WebSocket connections to the Electric URL
### Connection Refused Errors
- Verify Electric SQL server is running: `docker ps | grep electric`
- Check the `NEXT_PUBLIC_ELECTRIC_URL` matches your Electric server address
- For Docker setups, ensure the frontend can reach the Electric container
### Data Not Syncing
- Check Electric SQL logs: `docker logs electric`
- Verify PostgreSQL replication is working
- Ensure the Electric user has proper table permissions
### PGlite/IndexedDB Issues
If data appears stale or corrupted in the browser:
1. Open browser DevTools → Application → IndexedDB
2. Delete databases starting with `surfsense-`
3. Refresh the page - PGlite will recreate the local database and resync

View file

@ -1,5 +0,0 @@
{
"title": "How to",
"pages": ["electric-sql"],
"defaultOpen": false
}

View file

@ -8,7 +8,6 @@
"installation",
"docker-installation",
"manual-installation",
"connectors",
"how-to"
"connectors"
]
}

View file

@ -1,5 +1,4 @@
export enum EnumConnectorName {
SERPER_API = "SERPER_API",
TAVILY_API = "TAVILY_API",
SEARXNG_API = "SEARXNG_API",
LINKUP_API = "LINKUP_API",

View file

@ -6,11 +6,9 @@ export const documentTypeEnum = z.enum([
"CRAWLED_URL",
"FILE",
"SLACK_CONNECTOR",
"TEAMS_CONNECTOR",
"NOTION_CONNECTOR",
"YOUTUBE_VIDEO",
"GITHUB_CONNECTOR",
"LINEAR_CONNECTOR",
"DISCORD_CONNECTOR",
"JIRA_CONNECTOR",
"CONFLUENCE_CONNECTOR",
@ -21,10 +19,10 @@ export const documentTypeEnum = z.enum([
"AIRTABLE_CONNECTOR",
"LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR",
"BOOKSTACK_CONNECTOR",
"LINEAR_CONNECTOR",
"NOTE",
"CIRCLEBACK",
"SURFSENSE_DOCS",
"NOTE",
]);
export const document = z.object({

View file

@ -1,120 +0,0 @@
import { z } from "zod";
import { searchSourceConnectorTypeEnum } from "./connector.types";
import { documentTypeEnum } from "./document.types";
/**
* Notification type enum - matches backend notification types
*/
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing"]);
/**
* Notification status enum - used in metadata
*/
export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
/**
* Document processing stage enum
*/
export const documentProcessingStageEnum = z.enum([
"queued",
"parsing",
"chunking",
"embedding",
"storing",
"completed",
"failed",
]);
/**
* Base metadata schema shared across notification types
*/
export const baseNotificationMetadata = z.object({
operation_id: z.string().optional(),
status: notificationStatusEnum.optional(),
started_at: z.string().optional(),
completed_at: z.string().optional(),
});
/**
* Connector indexing metadata schema
*/
export const connectorIndexingMetadata = baseNotificationMetadata.extend({
connector_id: z.number(),
connector_name: z.string(),
connector_type: searchSourceConnectorTypeEnum,
start_date: z.string().nullable().optional(),
end_date: z.string().nullable().optional(),
indexed_count: z.number(),
total_count: z.number().optional(),
progress_percent: z.number().optional(),
error_message: z.string().nullable().optional(),
// Google Drive specific fields
folder_count: z.number().optional(),
file_count: z.number().optional(),
folder_names: z.array(z.string()).optional(),
file_names: z.array(z.string()).optional(),
});
/**
* Document processing metadata schema
*/
export const documentProcessingMetadata = baseNotificationMetadata.extend({
document_type: documentTypeEnum,
document_name: z.string(),
processing_stage: documentProcessingStageEnum,
file_size: z.number().optional(),
chunks_count: z.number().optional(),
document_id: z.number().optional(),
error_message: z.string().nullable().optional(),
});
/**
* Union of all notification metadata types
* Use this when the notification type is unknown
*/
export const notificationMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
baseNotificationMetadata,
]);
/**
* Main notification schema
*/
export const notification = z.object({
id: z.number(),
user_id: z.string(),
search_space_id: z.number().nullable(),
type: notificationTypeEnum,
title: z.string(),
message: z.string(),
read: z.boolean(),
metadata: z.record(z.string(), z.unknown()),
created_at: z.string(),
updated_at: z.string().nullable(),
});
/**
* Typed notification schemas for specific notification types
*/
export const connectorIndexingNotification = notification.extend({
type: z.literal("connector_indexing"),
metadata: connectorIndexingMetadata,
});
export const documentProcessingNotification = notification.extend({
type: z.literal("document_processing"),
metadata: documentProcessingMetadata,
});
// Inferred types
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
export type Notification = z.infer<typeof notification>;
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;

View file

@ -1,203 +0,0 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useElectricClient } from "@/lib/electric/context";
import type { SyncHandle } from "@/lib/electric/client";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/**
* Hook for managing connectors with Electric SQL real-time sync
*
* Uses the Electric client from context (provided by ElectricProvider)
* instead of initializing its own - prevents race conditions and memory leaks
*/
export function useConnectorsElectric(searchSpaceId: number | string | null) {
// Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
const [connectors, setConnectors] = useState<SearchSourceConnector[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const syncKeyRef = useRef<string | null>(null);
// Transform connector data from Electric SQL/PGlite to match expected format
function transformConnector(connector: any): SearchSourceConnector {
return {
...connector,
last_indexed_at: connector.last_indexed_at
? typeof connector.last_indexed_at === "string"
? connector.last_indexed_at
: new Date(connector.last_indexed_at).toISOString()
: null,
next_scheduled_at: connector.next_scheduled_at
? typeof connector.next_scheduled_at === "string"
? connector.next_scheduled_at
: new Date(connector.next_scheduled_at).toISOString()
: null,
created_at: connector.created_at
? typeof connector.created_at === "string"
? connector.created_at
: new Date(connector.created_at).toISOString()
: new Date().toISOString(),
};
}
// Start syncing when Electric client is available
useEffect(() => {
// Wait for both searchSpaceId and Electric client to be available
if (!searchSpaceId || !electricClient) {
setLoading(!electricClient); // Still loading if waiting for Electric
if (!searchSpaceId) {
setConnectors([]);
}
return;
}
// Create a unique key for this sync to prevent duplicate subscriptions
const syncKey = `connectors_${searchSpaceId}`;
if (syncKeyRef.current === syncKey) {
// Already syncing for this search space
return;
}
let mounted = true;
syncKeyRef.current = syncKey;
async function startSync() {
try {
console.log("[useConnectorsElectric] Starting sync for search space:", searchSpaceId);
const handle = await electricClient.syncShape({
table: "search_source_connectors",
where: `search_space_id = ${searchSpaceId}`,
primaryKey: ["id"],
});
console.log("[useConnectorsElectric] Sync started:", {
isUpToDate: handle.isUpToDate,
});
// Wait for initial sync with timeout
if (!handle.isUpToDate && handle.initialSyncPromise) {
try {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
} catch (syncErr) {
console.error("[useConnectorsElectric] Initial sync failed:", syncErr);
}
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
setLoading(false);
setError(null);
// Fetch initial connectors
await fetchConnectors();
// Set up live query for real-time updates
await setupLiveQuery();
} catch (err) {
if (!mounted) return;
console.error("[useConnectorsElectric] Failed to start sync:", err);
setError(err instanceof Error ? err : new Error("Failed to sync connectors"));
setLoading(false);
}
}
async function fetchConnectors() {
try {
const result = await electricClient.db.query(
`SELECT * FROM search_source_connectors WHERE search_space_id = $1 ORDER BY created_at DESC`,
[searchSpaceId]
);
if (mounted) {
setConnectors((result.rows || []).map(transformConnector));
}
} catch (err) {
console.error("[useConnectorsElectric] Failed to fetch:", err);
}
}
async function setupLiveQuery() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(
`SELECT * FROM search_source_connectors WHERE search_space_id = $1 ORDER BY created_at DESC`,
[searchSpaceId]
);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results
if (liveQuery.initialResults?.rows) {
setConnectors(liveQuery.initialResults.rows.map(transformConnector));
} else if (liveQuery.rows) {
setConnectors(liveQuery.rows.map(transformConnector));
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: any[] }) => {
if (mounted && result.rows) {
setConnectors(result.rows.map(transformConnector));
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
liveQueryRef.current = liveQuery;
}
}
} catch (liveErr) {
console.error("[useConnectorsElectric] Failed to set up live query:", liveErr);
}
}
startSync();
return () => {
mounted = false;
syncKeyRef.current = null;
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [searchSpaceId, electricClient]);
// Manual refresh function (optional, for fallback)
const refreshConnectors = useCallback(async () => {
if (!electricClient) return;
try {
const result = await electricClient.db.query(
`SELECT * FROM search_source_connectors WHERE search_space_id = $1 ORDER BY created_at DESC`,
[searchSpaceId]
);
setConnectors((result.rows || []).map(transformConnector));
} catch (err) {
console.error("[useConnectorsElectric] Failed to refresh:", err);
}
}, [electricClient, searchSpaceId]);
return { connectors, loading, error, refreshConnectors };
}

View file

@ -1,185 +0,0 @@
"use client";
import { useEffect, useState, useRef, useMemo } from "react";
import { useElectricClient } from "@/lib/electric/context";
import type { SyncHandle } from "@/lib/electric/client";
interface Document {
id: number;
search_space_id: number;
document_type: string;
created_at: string;
}
/**
* Hook for managing documents with Electric SQL real-time sync
*
* Uses the Electric client from context (provided by ElectricProvider)
* instead of initializing its own - prevents race conditions and memory leaks
*/
export function useDocumentsElectric(searchSpaceId: number | string | null) {
// Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const syncKeyRef = useRef<string | null>(null);
// Calculate document type counts from synced documents
const documentTypeCounts = useMemo(() => {
if (!documents.length) return {};
const counts: Record<string, number> = {};
for (const doc of documents) {
counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
}
return counts;
}, [documents]);
// Start syncing when Electric client is available
useEffect(() => {
// Wait for both searchSpaceId and Electric client to be available
if (!searchSpaceId || !electricClient) {
setLoading(!electricClient); // Still loading if waiting for Electric
if (!searchSpaceId) {
setDocuments([]);
}
return;
}
// Create a unique key for this sync to prevent duplicate subscriptions
const syncKey = `documents_${searchSpaceId}`;
if (syncKeyRef.current === syncKey) {
// Already syncing for this search space
return;
}
let mounted = true;
syncKeyRef.current = syncKey;
async function startSync() {
try {
console.log("[useDocumentsElectric] Starting sync for search space:", searchSpaceId);
const handle = await electricClient.syncShape({
table: "documents",
where: `search_space_id = ${searchSpaceId}`,
columns: ["id", "document_type", "search_space_id", "created_at"],
primaryKey: ["id"],
});
console.log("[useDocumentsElectric] Sync started:", {
isUpToDate: handle.isUpToDate,
});
// Wait for initial sync with timeout
if (!handle.isUpToDate && handle.initialSyncPromise) {
try {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
} catch (syncErr) {
console.error("[useDocumentsElectric] Initial sync failed:", syncErr);
}
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
setLoading(false);
setError(null);
// Fetch initial documents
await fetchDocuments();
// Set up live query for real-time updates
await setupLiveQuery();
} catch (err) {
if (!mounted) return;
console.error("[useDocumentsElectric] Failed to start sync:", err);
setError(err instanceof Error ? err : new Error("Failed to sync documents"));
setLoading(false);
}
}
async function fetchDocuments() {
try {
const result = await electricClient.db.query<Document>(
`SELECT id, document_type, search_space_id, created_at FROM documents WHERE search_space_id = $1 ORDER BY created_at DESC`,
[searchSpaceId]
);
if (mounted) {
setDocuments(result.rows || []);
}
} catch (err) {
console.error("[useDocumentsElectric] Failed to fetch:", err);
}
}
async function setupLiveQuery() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(
`SELECT id, document_type, search_space_id, created_at FROM documents WHERE search_space_id = $1 ORDER BY created_at DESC`,
[searchSpaceId]
);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results
if (liveQuery.initialResults?.rows) {
setDocuments(liveQuery.initialResults.rows);
} else if (liveQuery.rows) {
setDocuments(liveQuery.rows);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: Document[] }) => {
if (mounted && result.rows) {
setDocuments(result.rows);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
liveQueryRef.current = liveQuery;
}
}
} catch (liveErr) {
console.error("[useDocumentsElectric] Failed to set up live query:", liveErr);
}
}
startSync();
return () => {
mounted = false;
syncKeyRef.current = null;
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [searchSpaceId, electricClient]);
return { documentTypeCounts, loading, error };
}

View file

@ -1,248 +0,0 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useElectricClient } from "@/lib/electric/context";
import type { SyncHandle } from "@/lib/electric/client";
import type { Notification } from "@/contracts/types/notification.types";
import { authenticatedFetch } from "@/lib/auth-utils";
export type { Notification } from "@/contracts/types/notification.types";
/**
* Hook for managing notifications with Electric SQL real-time sync
*
* Uses the Electric client from context (provided by ElectricProvider)
* instead of initializing its own - prevents race conditions and memory leaks
*
* Architecture:
* - User-level sync: Syncs ALL notifications for a user (runs once per user)
* - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change)
*
* This separation ensures smooth transitions when switching search spaces (no flash).
*
* @param userId - The user ID to fetch notifications for
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
*/
export function useNotifications(userId: string | null, searchSpaceId: number | null) {
// Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Track user-level sync key to prevent duplicate sync subscriptions
const userSyncKeyRef = useRef<string | null>(null);
// EFFECT 1: User-level sync - runs once per user, syncs ALL notifications
useEffect(() => {
if (!userId || !electricClient) {
setLoading(!electricClient);
return;
}
const userSyncKey = `notifications_${userId}`;
if (userSyncKeyRef.current === userSyncKey) {
// Already syncing for this user
return;
}
let mounted = true;
userSyncKeyRef.current = userSyncKey;
async function startUserSync() {
try {
console.log("[useNotifications] Starting user-level sync for:", userId);
// Sync ALL notifications for this user (cached via syncShape caching)
const handle = await electricClient.syncShape({
table: "notifications",
where: `user_id = '${userId}'`,
primaryKey: ["id"],
});
console.log("[useNotifications] User sync started:", {
isUpToDate: handle.isUpToDate,
});
// Wait for initial sync with timeout
if (!handle.isUpToDate && handle.initialSyncPromise) {
try {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
} catch (syncErr) {
console.error("[useNotifications] Initial sync failed:", syncErr);
}
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
setLoading(false);
setError(null);
} catch (err) {
if (!mounted) return;
console.error("[useNotifications] Failed to start user sync:", err);
setError(err instanceof Error ? err : new Error("Failed to sync notifications"));
setLoading(false);
}
}
startUserSync();
return () => {
mounted = false;
userSyncKeyRef.current = null;
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
};
}, [userId, electricClient]);
// EFFECT 2: Search-space-level query - updates when searchSpaceId changes
// This runs independently of sync, allowing smooth transitions between search spaces
useEffect(() => {
if (!userId || !electricClient) {
return;
}
let mounted = true;
async function updateQuery() {
// Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
try {
console.log("[useNotifications] Updating query for searchSpace:", searchSpaceId);
// Fetch notifications for current search space immediately
const result = await electricClient.db.query<Notification>(
`SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
ORDER BY created_at DESC`,
[userId, searchSpaceId]
);
if (mounted) {
setNotifications(result.rows || []);
}
// Set up live query for real-time updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(
`SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
ORDER BY created_at DESC`,
[userId, searchSpaceId]
);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results from live query
if (liveQuery.initialResults?.rows) {
setNotifications(liveQuery.initialResults.rows);
} else if (liveQuery.rows) {
setNotifications(liveQuery.rows);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: Notification[] }) => {
if (mounted && result.rows) {
setNotifications(result.rows);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
liveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useNotifications] Failed to update query:", err);
}
}
updateQuery();
return () => {
mounted = false;
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, electricClient]);
// Mark notification as read via backend API
const markAsRead = useCallback(async (notificationId: number) => {
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
{ method: "PATCH" }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
throw new Error(error.detail || "Failed to mark notification as read");
}
return true;
} catch (err) {
console.error("Failed to mark notification as read:", err);
return false;
}
}, []);
// Mark all notifications as read via backend API
const markAllAsRead = useCallback(async () => {
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
{ method: "PATCH" }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
throw new Error(error.detail || "Failed to mark all notifications as read");
}
return true;
} catch (err) {
console.error("Failed to mark all notifications as read:", err);
return false;
}
}, []);
// Get unread count
const unreadCount = notifications.filter((n) => !n.read).length;
return {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
loading,
error,
};
}

View file

@ -1,20 +0,0 @@
/**
* Get auth token for Electric SQL
* In production, this should get the token from your auth system
*/
export async function getElectricAuthToken(): Promise<string> {
// For insecure mode (development), return empty string
if (process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE === "insecure") {
return "";
}
// In production, get token from your auth system
// This should match your backend auth token
if (typeof window !== "undefined") {
const token = localStorage.getItem("surfsense_bearer_token");
return token || "";
}
return "";
}

View file

@ -1,665 +0,0 @@
/**
* Electric SQL client setup for ElectricSQL 1.x with PGlite
*
* USER-SPECIFIC DATABASE ARCHITECTURE:
* - Each user gets their own IndexedDB database: idb://surfsense-{userId}-v{version}
* - On login: cleanup databases from other users, then initialize current user's DB
* - On logout: best-effort cleanup (not relied upon)
*
* This ensures:
* 1. Complete user isolation (data can never leak between users)
* 2. Self-healing on login (stale databases are cleaned up)
* 3. Works even if logout cleanup fails
*/
import { PGlite } from "@electric-sql/pglite";
import { electricSync } from "@electric-sql/pglite-sync";
import { live } from "@electric-sql/pglite/live";
// Types
export interface ElectricClient {
db: PGlite;
userId: string;
syncShape: (options: SyncShapeOptions) => Promise<SyncHandle>;
}
export interface SyncShapeOptions {
table: string;
where?: string;
columns?: string[];
primaryKey?: string[];
}
export interface SyncHandle {
unsubscribe: () => void;
readonly isUpToDate: boolean;
// The stream property contains the ShapeStreamInterface from pglite-sync
stream?: unknown;
// Promise that resolves when initial sync is complete
initialSyncPromise?: Promise<void>;
}
// Singleton state - now tracks the user ID
let electricClient: ElectricClient | null = null;
let currentUserId: string | null = null;
let isInitializing = false;
let initPromise: Promise<ElectricClient> | null = null;
// Cache for sync handles to prevent duplicate subscriptions (memory optimization)
const activeSyncHandles = new Map<string, SyncHandle>();
// Version for sync state - increment this to force fresh sync when Electric config changes
// Set to v2 for user-specific database architecture
const SYNC_VERSION = 2;
// Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-";
// Get Electric URL from environment
function getElectricUrl(): string {
if (typeof window !== "undefined") {
return process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133";
}
return "http://localhost:5133";
}
/**
* Get the database name for a specific user
*/
function getDbName(userId: string): string {
return `idb://${DB_PREFIX}${userId}-v${SYNC_VERSION}`;
}
/**
* Clean up databases from OTHER users (not the current user)
* This is called on login to ensure clean state
*/
async function cleanupOtherUserDatabases(currentUserId: string): Promise<void> {
if (typeof window === "undefined" || !window.indexedDB) {
return;
}
try {
// Try to list all databases (not supported in all browsers)
if (typeof window.indexedDB.databases === "function") {
const databases = await window.indexedDB.databases();
for (const dbInfo of databases) {
const dbName = dbInfo.name;
if (!dbName) continue;
// Check if this is a SurfSense database
if (dbName.startsWith(DB_PREFIX) || dbName.includes("surfsense")) {
// Don't delete current user's database
if (dbName.includes(currentUserId)) {
console.log(`[Electric] Keeping current user's database: ${dbName}`);
continue;
}
// Delete databases from other users
try {
console.log(`[Electric] Deleting stale database: ${dbName}`);
window.indexedDB.deleteDatabase(dbName);
} catch (deleteErr) {
console.warn(`[Electric] Failed to delete database ${dbName}:`, deleteErr);
}
}
}
}
} catch (err) {
// indexedDB.databases() not supported - that's okay, login cleanup is best-effort
console.warn("[Electric] Could not enumerate databases for cleanup:", err);
}
}
/**
* Initialize the Electric SQL client for a specific user
*
* KEY BEHAVIORS:
* 1. If already initialized for the SAME user, returns existing client
* 2. If initialized for a DIFFERENT user, closes old client and creates new one
* 3. On first init, cleans up databases from other users
*
* @param userId - The current user's ID (required)
*/
export async function initElectric(userId: string): Promise<ElectricClient> {
if (!userId) {
throw new Error("userId is required for Electric initialization");
}
// If already initialized for this user, return existing client
if (electricClient && currentUserId === userId) {
return electricClient;
}
// If initialized for a different user, close the old client first
if (electricClient && currentUserId !== userId) {
console.log(`[Electric] User changed from ${currentUserId} to ${userId}, reinitializing...`);
await cleanupElectric();
}
// If already initializing, wait for it
if (isInitializing && initPromise) {
return initPromise;
}
isInitializing = true;
currentUserId = userId;
initPromise = (async () => {
try {
// STEP 1: Clean up databases from other users (login-time cleanup)
console.log("[Electric] Cleaning up databases from other users...");
await cleanupOtherUserDatabases(userId);
// STEP 2: Create user-specific PGlite database
const dbName = getDbName(userId);
console.log(`[Electric] Initializing database: ${dbName}`);
const db = await PGlite.create({
dataDir: dbName,
relaxedDurability: true,
extensions: {
// Enable debug mode in electricSync to see detailed sync logs
electric: electricSync({ debug: true }),
live, // Enable live queries for real-time updates
},
});
// STEP 3: Create the notifications table schema in PGlite
// This matches the backend schema
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
search_space_id INTEGER,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read);
`);
// Create the search_source_connectors table schema in PGlite
// This matches the backend schema
await db.exec(`
CREATE TABLE IF NOT EXISTS search_source_connectors (
id INTEGER PRIMARY KEY,
search_space_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
connector_type TEXT NOT NULL,
name TEXT NOT NULL,
is_indexable BOOLEAN NOT NULL DEFAULT FALSE,
last_indexed_at TIMESTAMPTZ,
config JSONB DEFAULT '{}',
periodic_indexing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
indexing_frequency_minutes INTEGER,
next_scheduled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_connectors_search_space_id ON search_source_connectors(search_space_id);
CREATE INDEX IF NOT EXISTS idx_connectors_type ON search_source_connectors(connector_type);
CREATE INDEX IF NOT EXISTS idx_connectors_user_id ON search_source_connectors(user_id);
`);
// Create the documents table schema in PGlite
// Only sync minimal fields needed for type counts: id, document_type, search_space_id
await db.exec(`
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY,
search_space_id INTEGER NOT NULL,
document_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_documents_search_space_id ON documents(search_space_id);
CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(document_type);
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
`);
const electricUrl = getElectricUrl();
// STEP 4: Create the client wrapper
electricClient = {
db,
userId,
syncShape: async (options: SyncShapeOptions): Promise<SyncHandle> => {
const { table, where, columns, primaryKey = ["id"] } = options;
// Create cache key for this sync shape
const cacheKey = `${table}_${where || "all"}_${columns?.join(",") || "all"}`;
// Check if we already have an active sync for this shape (memory optimization)
const existingHandle = activeSyncHandles.get(cacheKey);
if (existingHandle) {
console.log(`[Electric] Reusing existing sync handle for: ${cacheKey}`);
return existingHandle;
}
// Build params for the shape request
// Electric SQL expects params as URL query parameters
const params: Record<string, string> = { table };
// Validate and fix WHERE clause to ensure string literals are properly quoted
let validatedWhere = where;
if (where) {
// Check if where uses positional parameters
if (where.includes("$1")) {
// Extract the value from the where clause if it's embedded
// For now, we'll use the where clause as-is and let Electric handle it
params.where = where;
validatedWhere = where;
} else {
// Validate that string literals are properly quoted
// Count single quotes - should be even (pairs) for properly quoted strings
const singleQuoteCount = (where.match(/'/g) || []).length;
if (singleQuoteCount % 2 !== 0) {
// Odd number of quotes means unterminated string literal
console.warn("Where clause has unmatched quotes, fixing:", where);
// Add closing quote at the end
validatedWhere = `${where}'`;
params.where = validatedWhere;
} else {
// Use the where clause directly (already formatted)
params.where = where;
validatedWhere = where;
}
}
}
if (columns) params.columns = columns.join(",");
console.log("[Electric] Syncing shape with params:", params);
console.log("[Electric] Electric URL:", `${electricUrl}/v1/shape`);
console.log("[Electric] Where clause:", where, "Validated:", validatedWhere);
try {
// Debug: Test Electric SQL connection directly first
// Use validatedWhere to ensure proper URL encoding
const testUrl = `${electricUrl}/v1/shape?table=${table}&offset=-1${validatedWhere ? `&where=${encodeURIComponent(validatedWhere)}` : ""}`;
console.log("[Electric] Testing Electric SQL directly:", testUrl);
try {
const testResponse = await fetch(testUrl);
const testHeaders = {
handle: testResponse.headers.get("electric-handle"),
offset: testResponse.headers.get("electric-offset"),
upToDate: testResponse.headers.get("electric-up-to-date"),
};
console.log("[Electric] Direct Electric SQL response headers:", testHeaders);
const testData = await testResponse.json();
console.log(
"[Electric] Direct Electric SQL data count:",
Array.isArray(testData) ? testData.length : "not array",
testData
);
} catch (testErr) {
console.error("[Electric] Direct Electric SQL test failed:", testErr);
}
// Use PGlite's electric sync plugin to sync the shape
// According to Electric SQL docs, the shape config uses params for table, where, columns
// Note: mapColumns is OPTIONAL per pglite-sync types.ts
// Create a promise that resolves when initial sync is complete
// Using recommended approach: check isUpToDate immediately, watch stream, shorter timeout
// IMPORTANT: We don't unsubscribe from the stream - it must stay active for real-time updates
let syncResolved = false;
// Initialize with no-op functions to satisfy TypeScript
let resolveInitialSync: () => void = () => {};
let rejectInitialSync: (error: Error) => void = () => {};
const initialSyncPromise = new Promise<void>((resolve, reject) => {
resolveInitialSync = () => {
if (!syncResolved) {
syncResolved = true;
// DON'T unsubscribe from stream - it needs to stay active for real-time updates
resolve();
}
};
rejectInitialSync = (error: Error) => {
if (!syncResolved) {
syncResolved = true;
// DON'T unsubscribe from stream even on error - let Electric handle it
reject(error);
}
};
// Shorter timeout (5 seconds) as fallback
setTimeout(() => {
if (!syncResolved) {
console.warn(
`[Electric] ⚠️ Sync timeout for ${table} - checking isUpToDate one more time...`
);
// Check isUpToDate one more time before resolving
// This will be checked after shape is created
setTimeout(() => {
if (!syncResolved) {
console.warn(
`[Electric] ⚠️ Sync timeout for ${table} - resolving anyway after 5s`
);
resolveInitialSync();
}
}, 100);
}
}, 5000);
});
// Include userId in shapeKey for user-specific sync state
const shapeConfig = {
shape: {
url: `${electricUrl}/v1/shape`,
params: {
table,
...(validatedWhere ? { where: validatedWhere } : {}),
...(columns ? { columns: columns.join(",") } : {}),
},
},
table,
primaryKey,
shapeKey: `${userId}_v${SYNC_VERSION}_${table}_${where?.replace(/[^a-zA-Z0-9]/g, "_") || "all"}`, // User-specific versioned key
onInitialSync: () => {
console.log(
`[Electric] ✅ Initial sync complete for ${table} - data should now be in PGlite`
);
resolveInitialSync();
},
onError: (error: Error) => {
console.error(`[Electric] ❌ Shape sync error for ${table}:`, error);
console.error(
"[Electric] Error details:",
JSON.stringify(error, Object.getOwnPropertyNames(error))
);
rejectInitialSync(error);
},
};
console.log(
"[Electric] syncShapeToTable config:",
JSON.stringify(shapeConfig, null, 2)
);
// Type assertion to PGlite with electric extension
const pgWithElectric = db as PGlite & {
electric: {
syncShapeToTable: (
config: typeof shapeConfig
) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>;
};
};
const shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
if (!shape) {
throw new Error("syncShapeToTable returned undefined");
}
// Log the actual shape result structure
console.log("[Electric] Shape sync result (initial):", {
hasUnsubscribe: typeof shape?.unsubscribe === "function",
isUpToDate: shape?.isUpToDate,
hasStream: !!shape?.stream,
streamType: typeof shape?.stream,
});
// Recommended Approach Step 1: Check isUpToDate immediately
if (shape.isUpToDate) {
console.log(
`[Electric] ✅ Sync already up-to-date for ${table} (resuming from previous state)`
);
resolveInitialSync();
} else {
// Recommended Approach Step 2: Subscribe to stream and watch for "up-to-date" message
if (shape?.stream) {
const stream = shape.stream as any;
console.log("[Electric] Shape stream details:", {
shapeHandle: stream?.shapeHandle,
lastOffset: stream?.lastOffset,
isUpToDate: stream?.isUpToDate,
error: stream?.error,
hasSubscribe: typeof stream?.subscribe === "function",
hasUnsubscribe: typeof stream?.unsubscribe === "function",
});
// Subscribe to the stream to watch for "up-to-date" control message
// NOTE: We keep this subscription active - don't unsubscribe!
// The stream is what Electric SQL uses for real-time updates
if (typeof stream?.subscribe === "function") {
console.log(
"[Electric] Subscribing to shape stream to watch for up-to-date message..."
);
// Subscribe but don't store unsubscribe - we want it to stay active
stream.subscribe((messages: unknown[]) => {
// Continue receiving updates even after sync is resolved
if (!syncResolved) {
console.log(
"[Electric] 🔵 Shape stream received messages:",
messages?.length || 0
);
}
// Check if any message indicates sync is complete
if (messages && messages.length > 0) {
for (const message of messages) {
const msg = message as any;
// Check for "up-to-date" control message
if (
msg?.headers?.control === "up-to-date" ||
msg?.headers?.electric_up_to_date === "true" ||
(typeof msg === "object" && "up-to-date" in msg)
) {
if (!syncResolved) {
console.log(`[Electric] ✅ Received up-to-date message for ${table}`);
resolveInitialSync();
}
// Continue listening for real-time updates - don't return!
}
}
if (!syncResolved && messages.length > 0) {
console.log(
"[Electric] First message:",
JSON.stringify(messages[0], null, 2)
);
}
}
// Also check stream's isUpToDate property after receiving messages
if (!syncResolved && stream?.isUpToDate) {
console.log(`[Electric] ✅ Stream isUpToDate is true for ${table}`);
resolveInitialSync();
}
});
// Also check stream's isUpToDate property immediately
if (stream?.isUpToDate) {
console.log(`[Electric] ✅ Stream isUpToDate is true immediately for ${table}`);
resolveInitialSync();
}
}
// Also poll isUpToDate periodically as a backup (every 200ms)
const pollInterval = setInterval(() => {
if (syncResolved) {
clearInterval(pollInterval);
return;
}
if (shape.isUpToDate || stream?.isUpToDate) {
console.log(`[Electric] ✅ Sync completed (detected via polling) for ${table}`);
clearInterval(pollInterval);
resolveInitialSync();
}
}, 200);
// Clean up polling when promise resolves
initialSyncPromise.finally(() => {
clearInterval(pollInterval);
});
} else {
console.warn(
`[Electric] ⚠️ No stream available for ${table}, relying on callback and timeout`
);
}
}
// Create the sync handle with proper cleanup
const syncHandle: SyncHandle = {
unsubscribe: () => {
console.log(`[Electric] Unsubscribing from: ${cacheKey}`);
// Remove from cache first
activeSyncHandles.delete(cacheKey);
// Then unsubscribe from the shape
if (shape && typeof shape.unsubscribe === "function") {
shape.unsubscribe();
}
},
// Use getter to always return current state
get isUpToDate() {
return shape?.isUpToDate ?? false;
},
stream: shape?.stream,
initialSyncPromise, // Expose promise so callers can wait for sync
};
// Cache the sync handle for reuse (memory optimization)
activeSyncHandles.set(cacheKey, syncHandle);
console.log(
`[Electric] Cached sync handle for: ${cacheKey} (total cached: ${activeSyncHandles.size})`
);
return syncHandle;
} catch (error) {
console.error("[Electric] Failed to sync shape:", error);
// Check if Electric SQL server is reachable
try {
const response = await fetch(`${electricUrl}/v1/shape?table=${table}&offset=-1`, {
method: "GET",
});
console.log(
"[Electric] Electric SQL server response:",
response.status,
response.statusText
);
if (!response.ok) {
console.error("[Electric] Electric SQL server error:", await response.text());
}
} catch (fetchError) {
console.error("[Electric] Cannot reach Electric SQL server:", fetchError);
console.error("[Electric] Make sure Electric SQL is running at:", electricUrl);
}
throw error;
}
},
};
console.log(`[Electric] ✅ Initialized successfully for user: ${userId}`);
return electricClient;
} catch (error) {
console.error("[Electric] Failed to initialize:", error);
// Reset state on failure
electricClient = null;
currentUserId = null;
throw error;
} finally {
isInitializing = false;
}
})();
return initPromise;
}
/**
* Cleanup Electric SQL - close database and reset singleton
* Called on logout (best-effort) and when switching users
*/
export async function cleanupElectric(): Promise<void> {
if (!electricClient) {
return;
}
const userIdToClean = currentUserId;
console.log(`[Electric] Cleaning up for user: ${userIdToClean}`);
// Unsubscribe from all active sync handles first (memory cleanup)
console.log(`[Electric] Unsubscribing from ${activeSyncHandles.size} active sync handles`);
// Copy keys to array to avoid mutation during iteration
const handleKeys = Array.from(activeSyncHandles.keys());
for (const key of handleKeys) {
const handle = activeSyncHandles.get(key);
if (handle) {
try {
handle.unsubscribe();
} catch (err) {
console.warn(`[Electric] Failed to unsubscribe from ${key}:`, err);
}
}
}
// Ensure cache is empty
activeSyncHandles.clear();
try {
// Close the PGlite database connection
await electricClient.db.close();
console.log("[Electric] Database closed");
} catch (error) {
console.error("[Electric] Error closing database:", error);
}
// Reset singleton state
electricClient = null;
currentUserId = null;
isInitializing = false;
initPromise = null;
// Delete the user's IndexedDB database (best-effort cleanup on logout)
if (typeof window !== "undefined" && window.indexedDB && userIdToClean) {
try {
const dbName = `${DB_PREFIX}${userIdToClean}-v${SYNC_VERSION}`;
window.indexedDB.deleteDatabase(dbName);
console.log(`[Electric] Deleted database: ${dbName}`);
} catch (err) {
console.warn("[Electric] Failed to delete database:", err);
}
}
console.log("[Electric] Cleanup complete");
}
/**
* Get the Electric client (throws if not initialized)
*/
export function getElectric(): ElectricClient {
if (!electricClient) {
throw new Error("Electric not initialized. Call initElectric(userId) first.");
}
return electricClient;
}
/**
* Check if Electric is initialized for a specific user
*/
export function isElectricInitialized(userId?: string): boolean {
if (!electricClient) return false;
if (userId && currentUserId !== userId) return false;
return true;
}
/**
* Get the current user ID that Electric is initialized for
*/
export function getCurrentElectricUserId(): string | null {
return currentUserId;
}
/**
* Get the PGlite database instance
*/
export function getDb(): PGlite | null {
return electricClient?.db ?? null;
}

View file

@ -1,36 +0,0 @@
"use client";
import { createContext, useContext } from "react";
import type { ElectricClient } from "./client";
/**
* Context for sharing the Electric SQL client across the app
*
* This ensures:
* 1. Single initialization point (ElectricProvider only)
* 2. No race conditions (hooks wait for context)
* 3. Clean cleanup (ElectricProvider manages lifecycle)
*/
export const ElectricContext = createContext<ElectricClient | null>(null);
/**
* Hook to get the Electric client from context
* Returns null if Electric is not initialized yet
*/
export function useElectricClient(): ElectricClient | null {
return useContext(ElectricContext);
}
/**
* Hook to get the Electric client, throwing if not available
* Use this when you're sure Electric should be initialized
*/
export function useElectricClientOrThrow(): ElectricClient {
const client = useContext(ElectricContext);
if (!client) {
throw new Error(
"Electric client not available. Make sure you're inside ElectricProvider and user is authenticated."
);
}
return client;
}

View file

@ -88,7 +88,7 @@
"description_label": "Description",
"description_placeholder": "What is this search space for?",
"create_button": "Create",
"creating": "Creating",
"creating": "Creating...",
"all_search_spaces": "All Search Spaces",
"search_spaces_count": "{count, plural, =0 {No search spaces} =1 {1 search space} other {# search spaces}}",
"no_search_spaces": "No search spaces yet",
@ -658,7 +658,7 @@
"sidebar": {
"chats": "Private Chats",
"shared_chats": "Shared Chats",
"search_chats": "Search chats",
"search_chats": "Search chats...",
"no_chats_found": "No chats found",
"no_shared_chats": "No shared chats",
"view_all_shared_chats": "View all shared chats",

View file

@ -29,10 +29,6 @@
"@blocknote/mantine": "^0.45.0",
"@blocknote/react": "^0.45.0",
"@blocknote/server-util": "^0.45.0",
"@electric-sql/client": "^1.4.0",
"@electric-sql/pglite": "^0.3.14",
"@electric-sql/pglite-sync": "^0.4.0",
"@electric-sql/react": "^1.0.26",
"@hookform/resolvers": "^5.2.2",
"@number-flow/react": "^0.5.10",
"@posthog/react": "^1.5.2",
@ -124,7 +120,6 @@
"eslint-config-next": "15.2.0",
"tailwindcss": "^4.1.11",
"tsx": "^4.20.6",
"typescript": "^5.8.3",
"vite": "^7.3.1"
"typescript": "^5.8.3"
}
}

File diff suppressed because it is too large Load diff