mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 21:02:40 +02:00
resolve conflicts
This commit is contained in:
commit
11b160cee9
85 changed files with 8411 additions and 4637 deletions
12
.env.example
12
.env.example
|
|
@ -9,7 +9,6 @@ FRONTEND_PORT=3000
|
||||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 (Default: http://localhost:8000)
|
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_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE (Default: LOCAL)
|
||||||
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING (Default: DOCLING)
|
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING (Default: DOCLING)
|
||||||
|
|
||||||
# Backend Configuration
|
# Backend Configuration
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
|
||||||
|
|
@ -19,6 +18,17 @@ POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_DB=surfsense
|
POSTGRES_DB=surfsense
|
||||||
POSTGRES_PORT=5432
|
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 Configuration
|
||||||
PGADMIN_PORT=5050
|
PGADMIN_PORT=5050
|
||||||
PGADMIN_DEFAULT_EMAIL=admin@surfsense.com
|
PGADMIN_DEFAULT_EMAIL=admin@surfsense.com
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# SurfSense All-in-One Docker Image
|
# SurfSense All-in-One Docker Image
|
||||||
# This image bundles PostgreSQL+pgvector, Redis, Backend, and Frontend
|
# This image bundles PostgreSQL+pgvector, Redis, Electric SQL, Backend, and Frontend
|
||||||
# Usage: docker run -d -p 3000:3000 -p 8000:8000 -v surfsense-data:/data --name surfsense ghcr.io/modsetter/surfsense:latest
|
# Usage: docker run -d -p 3000:3000 -p 8000:8000 -p 5133:5133 -v surfsense-data:/data --name surfsense ghcr.io/modsetter/surfsense:latest
|
||||||
#
|
#
|
||||||
# Included Services (all run locally by default):
|
# Included Services (all run locally by default):
|
||||||
# - PostgreSQL 14 + pgvector (vector database)
|
# - PostgreSQL 14 + pgvector (vector database)
|
||||||
# - Redis (task queue)
|
# - Redis (task queue)
|
||||||
|
# - Electric SQL (real-time sync)
|
||||||
# - Docling (document processing, CPU-only, OCR disabled)
|
# - Docling (document processing, CPU-only, OCR disabled)
|
||||||
# - Kokoro TTS (local text-to-speech for podcasts)
|
# - Kokoro TTS (local text-to-speech for podcasts)
|
||||||
# - Faster-Whisper (local speech-to-text for audio files)
|
# - Faster-Whisper (local speech-to-text for audio files)
|
||||||
|
|
@ -14,7 +15,12 @@
|
||||||
# will be available in the future for faster AI inference.
|
# will be available in the future for faster AI inference.
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
# Stage 1: Build Frontend
|
# Stage 1: Get Electric SQL Binary
|
||||||
|
# ====================
|
||||||
|
FROM electricsql/electric:latest AS electric-builder
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# Stage 2: Build Frontend
|
||||||
# ====================
|
# ====================
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
|
@ -42,12 +48,14 @@ RUN pnpm fumadocs-mdx
|
||||||
ENV NEXT_PUBLIC_FASTAPI_BACKEND_URL=__NEXT_PUBLIC_FASTAPI_BACKEND_URL__
|
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_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
|
||||||
ENV NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__
|
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
|
# Build
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
# Stage 2: Runtime Image
|
# Stage 3: Runtime Image
|
||||||
# ====================
|
# ====================
|
||||||
FROM ubuntu:22.04 AS runtime
|
FROM ubuntu:22.04 AS runtime
|
||||||
|
|
||||||
|
|
@ -167,6 +175,11 @@ COPY --from=frontend-builder /app/public ./public
|
||||||
|
|
||||||
COPY surfsense_web/content/docs /app/surfsense_web/content/docs
|
COPY surfsense_web/content/docs /app/surfsense_web/content/docs
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# Copy Electric SQL Release
|
||||||
|
# ====================
|
||||||
|
COPY --from=electric-builder /app /app/electric-release
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
# Setup Backend
|
# Setup Backend
|
||||||
# ====================
|
# ====================
|
||||||
|
|
@ -238,11 +251,22 @@ ENV NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
|
||||||
ENV NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL
|
ENV NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL
|
||||||
ENV NEXT_PUBLIC_ETL_SERVICE=DOCLING
|
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
|
# Data volume
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
# Expose ports
|
# Expose ports (Frontend: 3000, Backend: 8000, Electric: 5133)
|
||||||
EXPOSE 3000 8000
|
EXPOSE 3000 8000 5133
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,15 @@ services:
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- 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:
|
environment:
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-surfsense}
|
- 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:
|
pgadmin:
|
||||||
image: dpage/pgadmin4
|
image: dpage/pgadmin4
|
||||||
|
|
@ -51,11 +56,14 @@ services:
|
||||||
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
|
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
|
||||||
- LANGCHAIN_TRACING_V2=false
|
- LANGCHAIN_TRACING_V2=false
|
||||||
- LANGSMITH_TRACING=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:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
# Run these services seperately in production
|
# Run these services separately in production
|
||||||
# celery_worker:
|
# celery_worker:
|
||||||
# build: ./surfsense_backend
|
# build: ./surfsense_backend
|
||||||
# # image: ghcr.io/modsetter/surfsense_backend:latest
|
# # image: ghcr.io/modsetter/surfsense_backend:latest
|
||||||
|
|
@ -110,6 +118,23 @@ services:
|
||||||
# - redis
|
# - redis
|
||||||
# - celery_worker
|
# - 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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./surfsense_web
|
context: ./surfsense_web
|
||||||
|
|
@ -122,8 +147,12 @@ services:
|
||||||
- "${FRONTEND_PORT:-3000}:3000"
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
env_file:
|
env_file:
|
||||||
- ./surfsense_web/.env
|
- ./surfsense_web/.env
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_ELECTRIC_URL=${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:5133}
|
||||||
|
- NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
- electric
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,31 @@ if [ -z "$STT_SERVICE" ]; then
|
||||||
echo "✅ Using default STT_SERVICE: local/base"
|
echo "✅ Using default STT_SERVICE: local/base"
|
||||||
fi
|
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
|
# Initialize PostgreSQL if needed
|
||||||
# ================================================
|
# ================================================
|
||||||
|
|
@ -60,6 +85,11 @@ if [ ! -f /data/postgres/PG_VERSION ]; then
|
||||||
echo "local all all trust" >> /data/postgres/pg_hba.conf
|
echo "local all all trust" >> /data/postgres/pg_hba.conf
|
||||||
echo "listen_addresses='*'" >> /data/postgres/postgresql.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
|
# 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"
|
su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data/postgres -l /tmp/postgres_init.log start"
|
||||||
|
|
||||||
|
|
@ -73,6 +103,35 @@ if [ ! -f /data/postgres/PG_VERSION ]; then
|
||||||
# Enable pgvector extension
|
# Enable pgvector extension
|
||||||
su - postgres -c "psql -d ${POSTGRES_DB:-surfsense} -c 'CREATE EXTENSION IF NOT EXISTS vector;'"
|
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
|
# Stop temporary PostgreSQL
|
||||||
su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data/postgres stop"
|
su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data/postgres stop"
|
||||||
|
|
||||||
|
|
@ -107,18 +166,23 @@ echo "🔧 Applying runtime environment configuration..."
|
||||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL="${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:8000}"
|
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_FASTAPI_BACKEND_AUTH_TYPE="${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE:-LOCAL}"
|
||||||
NEXT_PUBLIC_ETL_SERVICE="${NEXT_PUBLIC_ETL_SERVICE:-DOCLING}"
|
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
|
# Replace placeholders in all JS files
|
||||||
find /app/frontend -type f \( -name "*.js" -o -name "*.json" \) -exec sed -i \
|
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_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_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_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 "✅ Environment configuration applied"
|
||||||
echo " Backend URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}"
|
echo " Backend URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}"
|
||||||
echo " Auth Type: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE}"
|
echo " Auth Type: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE}"
|
||||||
echo " ETL Service: ${NEXT_PUBLIC_ETL_SERVICE}"
|
echo " ETL Service: ${NEXT_PUBLIC_ETL_SERVICE}"
|
||||||
|
echo " Electric URL: ${NEXT_PUBLIC_ELECTRIC_URL}"
|
||||||
|
|
||||||
# ================================================
|
# ================================================
|
||||||
# Run database migrations
|
# Run database migrations
|
||||||
|
|
@ -161,6 +225,7 @@ echo "==========================================="
|
||||||
echo " Frontend URL: http://localhost:3000"
|
echo " Frontend URL: http://localhost:3000"
|
||||||
echo " Backend API: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}"
|
echo " Backend API: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}"
|
||||||
echo " API Docs: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL}/docs"
|
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 " Auth Type: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE}"
|
||||||
echo " ETL Service: ${NEXT_PUBLIC_ETL_SERVICE}"
|
echo " ETL Service: ${NEXT_PUBLIC_ETL_SERVICE}"
|
||||||
echo " TTS Service: ${TTS_SERVICE}"
|
echo " TTS Service: ${TTS_SERVICE}"
|
||||||
|
|
|
||||||
56
scripts/docker/init-electric-user.sh
Executable file
56
scripts/docker/init-electric-user.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/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"
|
||||||
|
|
@ -9,6 +9,10 @@ POSTGRES_USER=${POSTGRES_USER:-surfsense}
|
||||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-surfsense}
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-surfsense}
|
||||||
POSTGRES_DB=${POSTGRES_DB:-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..."
|
echo "Initializing PostgreSQL..."
|
||||||
|
|
||||||
# Check if PostgreSQL is already initialized
|
# Check if PostgreSQL is already initialized
|
||||||
|
|
@ -23,8 +27,18 @@ fi
|
||||||
# Configure PostgreSQL
|
# Configure PostgreSQL
|
||||||
cat >> "$PGDATA/postgresql.conf" << EOF
|
cat >> "$PGDATA/postgresql.conf" << EOF
|
||||||
listen_addresses = '*'
|
listen_addresses = '*'
|
||||||
max_connections = 100
|
max_connections = 200
|
||||||
shared_buffers = 128MB
|
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
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat >> "$PGDATA/pg_hba.conf" << EOF
|
cat >> "$PGDATA/pg_hba.conf" << EOF
|
||||||
|
|
@ -45,6 +59,15 @@ CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD' SUPERUSER;
|
||||||
CREATE DATABASE $POSTGRES_DB OWNER $POSTGRES_USER;
|
CREATE DATABASE $POSTGRES_DB OWNER $POSTGRES_USER;
|
||||||
\c $POSTGRES_DB
|
\c $POSTGRES_DB
|
||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
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
|
EOF
|
||||||
|
|
||||||
echo "PostgreSQL initialized successfully."
|
echo "PostgreSQL initialized successfully."
|
||||||
|
|
|
||||||
20
scripts/docker/postgresql.conf
Normal file
20
scripts/docker/postgresql.conf
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 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
|
||||||
|
|
@ -85,6 +85,20 @@ stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
environment=PYTHONPATH="/app/backend"
|
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
|
# Frontend
|
||||||
[program:frontend]
|
[program:frontend]
|
||||||
command=node server.js
|
command=node server.js
|
||||||
|
|
@ -102,6 +116,6 @@ environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
# Process Groups
|
# Process Groups
|
||||||
[group:surfsense]
|
[group:surfsense]
|
||||||
programs=postgresql,redis,backend,celery-worker,celery-beat,frontend
|
programs=postgresql,redis,electric,backend,celery-worker,celery-beat,frontend
|
||||||
priority=999
|
priority=999
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@ database_url = os.getenv("DATABASE_URL")
|
||||||
if database_url:
|
if database_url:
|
||||||
config.set_main_option("sqlalchemy.url", 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.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -574,6 +574,12 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
order_by="Log.id",
|
order_by="Log.id",
|
||||||
cascade="all, delete-orphan",
|
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(
|
search_source_connectors = relationship(
|
||||||
"SearchSourceConnector",
|
"SearchSourceConnector",
|
||||||
back_populates="search_space",
|
back_populates="search_space",
|
||||||
|
|
@ -712,6 +718,39 @@ class Log(BaseModel, TimestampMixin):
|
||||||
search_space = relationship("SearchSpace", back_populates="logs")
|
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):
|
class SearchSpaceRole(BaseModel, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Custom roles that can be defined per search space.
|
Custom roles that can be defined per search space.
|
||||||
|
|
@ -856,6 +895,12 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
"OAuthAccount", lazy="joined"
|
"OAuthAccount", lazy="joined"
|
||||||
)
|
)
|
||||||
search_spaces = relationship("SearchSpace", back_populates="user")
|
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
|
# RBAC relationships
|
||||||
search_space_memberships = relationship(
|
search_space_memberships = relationship(
|
||||||
|
|
@ -893,6 +938,12 @@ else:
|
||||||
|
|
||||||
class User(SQLAlchemyBaseUserTableUUID, Base):
|
class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||||
search_spaces = relationship("SearchSpace", back_populates="user")
|
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
|
# RBAC relationships
|
||||||
search_space_memberships = relationship(
|
search_space_memberships = relationship(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from .luma_add_connector_route import router as luma_add_connector_router
|
||||||
from .new_chat_routes import router as new_chat_router
|
from .new_chat_routes import router as new_chat_router
|
||||||
from .new_llm_config_routes import router as new_llm_config_router
|
from .new_llm_config_routes import router as new_llm_config_router
|
||||||
from .notes_routes import router as notes_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 .notion_add_connector_route import router as notion_add_connector_router
|
||||||
from .podcasts_routes import router as podcasts_router
|
from .podcasts_routes import router as podcasts_router
|
||||||
from .rbac_routes import router as rbac_router
|
from .rbac_routes import router as rbac_router
|
||||||
|
|
@ -61,3 +62,4 @@ router.include_router(new_llm_config_router) # LLM configs with prompt configur
|
||||||
router.include_router(logs_router)
|
router.include_router(logs_router)
|
||||||
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
||||||
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
||||||
|
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
||||||
|
|
|
||||||
102
surfsense_backend/app/routes/notifications_routes.py
Normal file
102
surfsense_backend/app/routes/notifications_routes.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load diff
664
surfsense_backend/app/services/notification_service.py
Normal file
664
surfsense_backend/app/services/notification_service.py
Normal file
|
|
@ -0,0 +1,664 @@
|
||||||
|
"""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
|
||||||
|
|
@ -445,31 +445,13 @@ async def _index_google_gmail_messages(
|
||||||
end_date: str,
|
end_date: str,
|
||||||
):
|
):
|
||||||
"""Index Google Gmail messages with new session."""
|
"""Index Google Gmail messages with new session."""
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.routes.search_source_connectors_routes import (
|
from app.routes.search_source_connectors_routes import (
|
||||||
run_google_gmail_indexing,
|
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:
|
async with get_celery_session_maker()() as session:
|
||||||
await run_google_gmail_indexing(
|
await run_google_gmail_indexing(
|
||||||
session, connector_id, search_space_id, user_id, max_messages, days_back
|
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
"""Celery tasks for document processing."""
|
"""Celery tasks for document processing."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.celery_app import celery_app
|
from app.celery_app import celery_app
|
||||||
from app.config import config
|
from app.config import config
|
||||||
|
from app.services.notification_service import NotificationService
|
||||||
from app.services.task_logging_service import TaskLoggingService
|
from app.services.task_logging_service import TaskLoggingService
|
||||||
from app.tasks.document_processors import (
|
from app.tasks.document_processors import (
|
||||||
add_extension_received_document,
|
add_extension_received_document,
|
||||||
|
|
@ -84,6 +86,22 @@ async def _process_extension_document(
|
||||||
async with get_celery_session_maker()() as session:
|
async with get_celery_session_maker()() as session:
|
||||||
task_logger = TaskLoggingService(session, search_space_id)
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_extension_document",
|
task_name="process_extension_document",
|
||||||
source="document_processor",
|
source="document_processor",
|
||||||
|
|
@ -97,6 +115,14 @@ async def _process_extension_document(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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(
|
result = await add_extension_received_document(
|
||||||
session, individual_document, search_space_id, user_id
|
session, individual_document, search_space_id, user_id
|
||||||
)
|
)
|
||||||
|
|
@ -107,12 +133,31 @@ async def _process_extension_document(
|
||||||
f"Successfully processed extension document: {individual_document.metadata.VisitedWebPageTitle}",
|
f"Successfully processed extension document: {individual_document.metadata.VisitedWebPageTitle}",
|
||||||
{"document_id": result.id, "content_hash": result.content_hash},
|
{"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:
|
else:
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Extension document already exists (duplicate): {individual_document.metadata.VisitedWebPageTitle}",
|
f"Extension document already exists (duplicate): {individual_document.metadata.VisitedWebPageTitle}",
|
||||||
{"duplicate_detected": True},
|
{"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:
|
except Exception as e:
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -120,6 +165,23 @@ async def _process_extension_document(
|
||||||
str(e),
|
str(e),
|
||||||
{"error_type": type(e).__name__},
|
{"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}")
|
logger.error(f"Error processing extension document: {e!s}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -150,6 +212,20 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
|
||||||
async with get_celery_session_maker()() as session:
|
async with get_celery_session_maker()() as session:
|
||||||
task_logger = TaskLoggingService(session, search_space_id)
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_youtube_video",
|
task_name="process_youtube_video",
|
||||||
source="document_processor",
|
source="document_processor",
|
||||||
|
|
@ -158,6 +234,14 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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(
|
result = await add_youtube_video_document(
|
||||||
session, url, search_space_id, user_id
|
session, url, search_space_id, user_id
|
||||||
)
|
)
|
||||||
|
|
@ -172,12 +256,31 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
|
||||||
"content_hash": result.content_hash,
|
"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:
|
else:
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"YouTube video document already exists (duplicate): {url}",
|
f"YouTube video document already exists (duplicate): {url}",
|
||||||
{"duplicate_detected": True},
|
{"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:
|
except Exception as e:
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -185,6 +288,23 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
|
||||||
str(e),
|
str(e),
|
||||||
{"error_type": type(e).__name__},
|
{"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}")
|
logger.error(f"Error processing YouTube video: {e!s}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -219,11 +339,31 @@ async def _process_file_upload(
|
||||||
file_path: str, filename: str, search_space_id: int, user_id: str
|
file_path: str, filename: str, search_space_id: int, user_id: str
|
||||||
):
|
):
|
||||||
"""Process file upload with new session."""
|
"""Process file upload with new session."""
|
||||||
|
import os
|
||||||
|
|
||||||
from app.tasks.document_processors.file_processors import process_file_in_background
|
from app.tasks.document_processors.file_processors import process_file_in_background
|
||||||
|
|
||||||
async with get_celery_session_maker()() as session:
|
async with get_celery_session_maker()() as session:
|
||||||
task_logger = TaskLoggingService(session, search_space_id)
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_file_upload",
|
task_name="process_file_upload",
|
||||||
source="document_processor",
|
source="document_processor",
|
||||||
|
|
@ -237,7 +377,7 @@ async def _process_file_upload(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await process_file_in_background(
|
result = await process_file_in_background(
|
||||||
file_path,
|
file_path,
|
||||||
filename,
|
filename,
|
||||||
search_space_id,
|
search_space_id,
|
||||||
|
|
@ -245,7 +385,29 @@ async def _process_file_upload(
|
||||||
session,
|
session,
|
||||||
task_logger,
|
task_logger,
|
||||||
log_entry,
|
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:
|
except Exception as e:
|
||||||
# Import here to avoid circular dependencies
|
# Import here to avoid circular dependencies
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -258,7 +420,23 @@ async def _process_file_upload(
|
||||||
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower():
|
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower():
|
||||||
error_message = str(e.detail)
|
error_message = str(e.detail)
|
||||||
else:
|
else:
|
||||||
error_message = f"Failed to process file: {filename}"
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -323,6 +501,22 @@ async def _process_circleback_meeting(
|
||||||
async with get_celery_session_maker()() as session:
|
async with get_celery_session_maker()() as session:
|
||||||
task_logger = TaskLoggingService(session, search_space_id)
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_circleback_meeting",
|
task_name="process_circleback_meeting",
|
||||||
source="circleback_webhook",
|
source="circleback_webhook",
|
||||||
|
|
@ -336,6 +530,17 @@ async def _process_circleback_meeting(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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(
|
result = await add_circleback_meeting_document(
|
||||||
session=session,
|
session=session,
|
||||||
meeting_id=meeting_id,
|
meeting_id=meeting_id,
|
||||||
|
|
@ -355,12 +560,29 @@ async def _process_circleback_meeting(
|
||||||
"content_hash": result.content_hash,
|
"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:
|
else:
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Circleback meeting document already exists (duplicate): {meeting_name}",
|
f"Circleback meeting document already exists (duplicate): {meeting_name}",
|
||||||
{"duplicate_detected": True, "meeting_id": meeting_id},
|
{"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:
|
except Exception as e:
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -368,5 +590,21 @@ async def _process_circleback_meeting(
|
||||||
str(e),
|
str(e),
|
||||||
{"error_type": type(e).__name__, "meeting_id": meeting_id},
|
{"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}")
|
logger.error(f"Error processing Circleback meeting: {e!s}")
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -423,9 +423,9 @@ async def stream_new_chat(
|
||||||
title = title[:27] + "..."
|
title = title[:27] + "..."
|
||||||
doc_names.append(title)
|
doc_names.append(title)
|
||||||
if len(doc_names) == 1:
|
if len(doc_names) == 1:
|
||||||
processing_parts.append(f"[📖 {doc_names[0]}]")
|
processing_parts.append(f"[{doc_names[0]}]")
|
||||||
else:
|
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)}"]
|
last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -549,7 +549,10 @@ async def index_discord_messages(
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Discord indexing completed: {documents_indexed} new messages, {documents_skipped} skipped"
|
f"Discord indexing completed: {documents_indexed} new messages, {documents_skipped} skipped"
|
||||||
)
|
)
|
||||||
return documents_indexed, result_message
|
return (
|
||||||
|
documents_indexed,
|
||||||
|
None,
|
||||||
|
) # Return None on success (result_message is for logging only)
|
||||||
|
|
||||||
except SQLAlchemyError as db_error:
|
except SQLAlchemyError as db_error:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -464,7 +464,10 @@ async def index_notion_pages(
|
||||||
# Clean up the async client
|
# Clean up the async client
|
||||||
await notion_client.close()
|
await notion_client.close()
|
||||||
|
|
||||||
return total_processed, result_message
|
return (
|
||||||
|
total_processed,
|
||||||
|
None,
|
||||||
|
) # Return None on success (result_message is for logging only)
|
||||||
|
|
||||||
except SQLAlchemyError as db_error:
|
except SQLAlchemyError as db_error:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,10 @@ async def index_slack_messages(
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Slack indexing completed: {documents_indexed} new channels, {documents_skipped} skipped"
|
f"Slack indexing completed: {documents_indexed} new channels, {documents_skipped} skipped"
|
||||||
)
|
)
|
||||||
return total_processed, result_message
|
return (
|
||||||
|
total_processed,
|
||||||
|
None,
|
||||||
|
) # Return None on success (result_message is for logging only)
|
||||||
|
|
||||||
except SQLAlchemyError as db_error:
|
except SQLAlchemyError as db_error:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -460,7 +460,10 @@ async def index_teams_messages(
|
||||||
documents_indexed,
|
documents_indexed,
|
||||||
documents_skipped,
|
documents_skipped,
|
||||||
)
|
)
|
||||||
return total_processed, result_message
|
return (
|
||||||
|
total_processed,
|
||||||
|
None,
|
||||||
|
) # Return None on success (result_message is for logging only)
|
||||||
|
|
||||||
except SQLAlchemyError as db_error:
|
except SQLAlchemyError as db_error:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -371,17 +371,14 @@ async def index_crawled_urls(
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Build result message
|
# Log failed URLs if any (for debugging purposes)
|
||||||
result_message = None
|
|
||||||
if failed_urls:
|
if failed_urls:
|
||||||
failed_summary = "; ".join(
|
failed_summary = "; ".join(
|
||||||
[f"{url}: {error}" for url, error in failed_urls[:5]]
|
[f"{url}: {error}" for url, error in failed_urls[:5]]
|
||||||
)
|
)
|
||||||
if len(failed_urls) > 5:
|
if len(failed_urls) > 5:
|
||||||
failed_summary += f" (and {len(failed_urls) - 5} more)"
|
failed_summary += f" (and {len(failed_urls) - 5} more)"
|
||||||
result_message = (
|
logger.warning(f"Some URLs failed to index: {failed_summary}")
|
||||||
f"Completed with {len(failed_urls)} failures: {failed_summary}"
|
|
||||||
)
|
|
||||||
|
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -400,7 +397,10 @@ async def index_crawled_urls(
|
||||||
f"{documents_updated} updated, {documents_skipped} skipped, "
|
f"{documents_updated} updated, {documents_skipped} skipped, "
|
||||||
f"{len(failed_urls)} failed"
|
f"{len(failed_urls)} failed"
|
||||||
)
|
)
|
||||||
return total_processed, result_message
|
return (
|
||||||
|
total_processed,
|
||||||
|
None,
|
||||||
|
) # Return None on success (result_message is for logging only)
|
||||||
|
|
||||||
except SQLAlchemyError as db_error:
|
except SQLAlchemyError as db_error:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import config as app_config
|
from app.config import config as app_config
|
||||||
from app.db import Document, DocumentType, Log
|
from app.db import Document, DocumentType, Log, Notification
|
||||||
from app.services.llm_service import get_user_long_context_llm
|
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.services.task_logging_service import TaskLoggingService
|
||||||
from app.utils.document_converters import (
|
from app.utils.document_converters import (
|
||||||
convert_document_to_markdown,
|
convert_document_to_markdown,
|
||||||
|
|
@ -594,10 +595,23 @@ async def process_file_in_background(
|
||||||
log_entry: Log,
|
log_entry: Log,
|
||||||
connector: dict
|
connector: dict
|
||||||
| None = None, # Optional: {"type": "GOOGLE_DRIVE_FILE", "metadata": {...}}
|
| None = None, # Optional: {"type": "GOOGLE_DRIVE_FILE", "metadata": {...}}
|
||||||
):
|
notification: Notification
|
||||||
|
| None = None, # Optional notification for progress updates
|
||||||
|
) -> Document | None:
|
||||||
try:
|
try:
|
||||||
# Check if the file is a markdown or text file
|
# Check if the file is a markdown or text file
|
||||||
if filename.lower().endswith((".md", ".markdown", ".txt")):
|
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(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Processing markdown/text file: {filename}",
|
f"Processing markdown/text file: {filename}",
|
||||||
|
|
@ -617,6 +631,14 @@ async def process_file_in_background(
|
||||||
print("Error deleting temp file", e)
|
print("Error deleting temp file", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Update notification: chunking stage
|
||||||
|
if notification:
|
||||||
|
await (
|
||||||
|
NotificationService.document_processing.notify_processing_progress(
|
||||||
|
session, notification, stage="chunking"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await task_logger.log_task_progress(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Creating document from markdown content: {filename}",
|
f"Creating document from markdown content: {filename}",
|
||||||
|
|
@ -644,17 +666,30 @@ async def process_file_in_background(
|
||||||
"file_type": "markdown",
|
"file_type": "markdown",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Markdown file already exists (duplicate): {filename}",
|
f"Markdown file already exists (duplicate): {filename}",
|
||||||
{"duplicate_detected": True, "file_type": "markdown"},
|
{"duplicate_detected": True, "file_type": "markdown"},
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Check if the file is an audio file
|
# Check if the file is an audio file
|
||||||
elif filename.lower().endswith(
|
elif filename.lower().endswith(
|
||||||
(".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm")
|
(".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(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Processing audio file for transcription: {filename}",
|
f"Processing audio file for transcription: {filename}",
|
||||||
|
|
@ -738,6 +773,14 @@ 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
|
# Clean up the temp file
|
||||||
try:
|
try:
|
||||||
os.unlink(file_path)
|
os.unlink(file_path)
|
||||||
|
|
@ -765,12 +808,14 @@ async def process_file_in_background(
|
||||||
"stt_service": stt_service_type,
|
"stt_service": stt_service_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Audio file transcript already exists (duplicate): {filename}",
|
f"Audio file transcript already exists (duplicate): {filename}",
|
||||||
{"duplicate_detected": True, "file_type": "audio"},
|
{"duplicate_detected": True, "file_type": "audio"},
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Import page limit service
|
# Import page limit service
|
||||||
|
|
@ -835,6 +880,15 @@ async def process_file_in_background(
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
if app_config.ETL_SERVICE == "UNSTRUCTURED":
|
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(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Processing file with Unstructured ETL: {filename}",
|
f"Processing file with Unstructured ETL: {filename}",
|
||||||
|
|
@ -860,6 +914,12 @@ async def process_file_in_background(
|
||||||
|
|
||||||
docs = await loader.aload()
|
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(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Unstructured ETL completed, creating document: {filename}",
|
f"Unstructured ETL completed, creating document: {filename}",
|
||||||
|
|
@ -919,6 +979,7 @@ async def process_file_in_background(
|
||||||
"pages_processed": final_page_count,
|
"pages_processed": final_page_count,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -929,8 +990,18 @@ async def process_file_in_background(
|
||||||
"etl_service": "UNSTRUCTURED",
|
"etl_service": "UNSTRUCTURED",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
elif app_config.ETL_SERVICE == "LLAMACLOUD":
|
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(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Processing file with LlamaCloud ETL: {filename}",
|
f"Processing file with LlamaCloud ETL: {filename}",
|
||||||
|
|
@ -964,6 +1035,15 @@ async def process_file_in_background(
|
||||||
split_by_page=False
|
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(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"LlamaCloud parsing completed, creating documents: {filename}",
|
f"LlamaCloud parsing completed, creating documents: {filename}",
|
||||||
|
|
@ -1056,6 +1136,7 @@ async def process_file_in_background(
|
||||||
"documents_count": len(markdown_documents),
|
"documents_count": len(markdown_documents),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return last_created_doc
|
||||||
else:
|
else:
|
||||||
# All documents were duplicates (markdown_documents was not empty, but all returned None)
|
# All documents were duplicates (markdown_documents was not empty, but all returned None)
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
|
|
@ -1068,8 +1149,18 @@ async def process_file_in_background(
|
||||||
"documents_count": len(markdown_documents),
|
"documents_count": len(markdown_documents),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
elif app_config.ETL_SERVICE == "DOCLING":
|
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(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Processing file with Docling ETL: {filename}",
|
f"Processing file with Docling ETL: {filename}",
|
||||||
|
|
@ -1152,6 +1243,12 @@ 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
|
# Process the document using our Docling background task
|
||||||
doc_result = await add_received_file_document_using_docling(
|
doc_result = await add_received_file_document_using_docling(
|
||||||
session,
|
session,
|
||||||
|
|
@ -1184,6 +1281,7 @@ async def process_file_in_background(
|
||||||
"pages_processed": final_page_count,
|
"pages_processed": final_page_count,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return doc_result
|
||||||
else:
|
else:
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -1194,6 +1292,7 @@ async def process_file_in_background(
|
||||||
"etl_service": "DOCLING",
|
"etl_service": "DOCLING",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,26 @@ cleanup() {
|
||||||
|
|
||||||
trap cleanup SIGTERM SIGINT
|
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..."
|
echo "Starting FastAPI Backend..."
|
||||||
python main.py &
|
python main.py &
|
||||||
backend_pid=$!
|
backend_pid=$!
|
||||||
|
|
|
||||||
6035
surfsense_backend/uv.lock
generated
6035
surfsense_backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,10 @@
|
||||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
|
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
|
||||||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE
|
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE
|
||||||
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
|
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
|
# Contact Form Vars - OPTIONAL
|
||||||
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres
|
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
|
import { ChevronDown, ChevronUp, FileX, Loader2, Plus } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
@ -114,7 +114,7 @@ export function DocumentsTableShell({
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-[400px] w-full items-center justify-center">
|
<div className="flex h-[400px] w-full items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,20 +6,18 @@ import { RefreshCw, SquarePlus, Upload } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
import { useLogsSummary } from "@/hooks/use-logs";
|
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { DocumentsFilters } from "./components/DocumentsFilters";
|
import { DocumentsFilters } from "./components/DocumentsFilters";
|
||||||
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
||||||
import { PaginationControls } from "./components/PaginationControls";
|
import { PaginationControls } from "./components/PaginationControls";
|
||||||
import { ProcessingIndicator } from "./components/ProcessingIndicator";
|
|
||||||
import type { ColumnVisibility } from "./components/types";
|
import type { ColumnVisibility } from "./components/types";
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 250) {
|
function useDebounced<T>(value: T, delay = 250) {
|
||||||
|
|
@ -109,6 +107,52 @@ export default function DocumentsTable() {
|
||||||
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
|
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
|
// Extract documents and total based on search state
|
||||||
const documents = debouncedSearch.trim()
|
const documents = debouncedSearch.trim()
|
||||||
? searchResponse?.items || []
|
? searchResponse?.items || []
|
||||||
|
|
@ -150,30 +194,6 @@ export default function DocumentsTable() {
|
||||||
}
|
}
|
||||||
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
|
}, [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
|
// Create a delete function for single document deletion
|
||||||
const deleteDocument = useCallback(
|
const deleteDocument = useCallback(
|
||||||
async (id: number) => {
|
async (id: number) => {
|
||||||
|
|
@ -262,8 +282,6 @@ export default function DocumentsTable() {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
|
|
||||||
|
|
||||||
<DocumentsFilters
|
<DocumentsFilters
|
||||||
typeCounts={rawTypeCounts ?? {}}
|
typeCounts={rawTypeCounts ?? {}}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
|
|
||||||
|
|
@ -438,9 +438,7 @@ export default function EditorPage() {
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
|
||||||
<span className="text-xs md:text-sm">
|
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
|
||||||
{isNewNote ? "Creating..." : "Saving..."}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1294,7 +1294,7 @@ function CreateInviteDialog({
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Creating...
|
Creating
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Create Invite"
|
"Create Invite"
|
||||||
|
|
@ -1471,7 +1471,7 @@ function CreateRoleDialog({
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Creating...
|
Creating
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Create Role"
|
"Create Role"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||||
import { Roboto } from "next/font/google";
|
import { Roboto } from "next/font/google";
|
||||||
|
import { ElectricProvider } from "@/components/providers/ElectricProvider";
|
||||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||||
import { PostHogProvider } from "@/components/providers/PostHogProvider";
|
import { PostHogProvider } from "@/components/providers/PostHogProvider";
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
|
|
@ -102,7 +103,9 @@ export default function RootLayout({
|
||||||
defaultTheme="light"
|
defaultTheme="light"
|
||||||
>
|
>
|
||||||
<RootProvider>
|
<RootProvider>
|
||||||
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
|
<ReactQueryClientProvider>
|
||||||
|
<ElectricProvider>{children}</ElectricProvider>
|
||||||
|
</ReactQueryClientProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
|
|
||||||
export function UserDropdown({
|
export function UserDropdown({
|
||||||
|
|
@ -26,12 +27,20 @@ export function UserDropdown({
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
// Track logout event and reset PostHog identity
|
// Track logout event and reset PostHog identity
|
||||||
trackLogout();
|
trackLogout();
|
||||||
resetUser();
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("surfsense_bearer_token");
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
|
@ -40,7 +49,7 @@ export function UserDropdown({
|
||||||
console.error("Error during logout:", error);
|
console.error("Error during logout:", error);
|
||||||
// Optionally, provide user feedback
|
// Optionally, provide user feedback
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
alert("Logout failed. Please try again.");
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ export const ComposerAddAttachment: FC = () => {
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
|
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
<span>Upload Files</span>
|
<span>Upload Documents</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Cable, Loader2 } from "lucide-react";
|
import { Cable, Loader2 } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { type FC, useEffect, useMemo } from "react";
|
import type { FC } from "react";
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { useLogsSummary } from "@/hooks/use-logs";
|
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
||||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||||
|
|
@ -21,6 +18,7 @@ import { ConnectorEditView } from "./connector-popup/connector-configs/views/con
|
||||||
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
||||||
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
|
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
|
||||||
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
|
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 { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||||
|
|
@ -30,18 +28,13 @@ import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view
|
||||||
export const ConnectorIndicator: FC = () => {
|
export const ConnectorIndicator: FC = () => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
|
|
||||||
useAtomValue(documentTypeCountsAtom);
|
// Fetch document type counts using Electric SQL + PGlite for real-time updates
|
||||||
|
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
|
||||||
|
|
||||||
// Check if YouTube view is active
|
// Check if YouTube view is active
|
||||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
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
|
// Use the custom hook for dialog state management
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
|
|
@ -97,57 +90,35 @@ export const ConnectorIndicator: FC = () => {
|
||||||
setConnectorName,
|
setConnectorName,
|
||||||
} = useConnectorDialog();
|
} = useConnectorDialog();
|
||||||
|
|
||||||
// Fetch connectors using React Query with conditional refetchInterval
|
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
||||||
// This automatically refetches when mutations invalidate the cache (event-driven)
|
// This provides instant updates when connectors change, without polling
|
||||||
// and also polls when dialog is open to catch external changes
|
|
||||||
const {
|
const {
|
||||||
data: connectors = [],
|
connectors: connectorsFromElectric = [],
|
||||||
isLoading: connectorsLoading,
|
loading: connectorsLoading,
|
||||||
refetch: refreshConnectors,
|
error: connectorsError,
|
||||||
} = useQuery({
|
refreshConnectors: refreshConnectorsElectric,
|
||||||
queryKey: cacheKeys.connectors.all(searchSpaceId || ""),
|
} = useConnectorsElectric(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
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
// Fallback to API if Electric fails or is not available
|
||||||
|
const connectors =
|
||||||
|
connectorsFromElectric.length > 0 || !connectorsError
|
||||||
|
? connectorsFromElectric
|
||||||
|
: allConnectors || [];
|
||||||
|
|
||||||
// Also refresh document type counts when dialog is open
|
// Manual refresh function that works with both Electric and API
|
||||||
useEffect(() => {
|
const refreshConnectors = async () => {
|
||||||
if (!isOpen || !searchSpaceId) return;
|
if (connectorsFromElectric.length > 0 || !connectorsError) {
|
||||||
|
await refreshConnectorsElectric();
|
||||||
|
} else {
|
||||||
|
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
|
||||||
|
// The connectorsAtom will handle refetching if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const POLL_INTERVAL = 5000; // 5 seconds, same as connectors
|
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
|
||||||
|
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
|
||||||
const intervalId = setInterval(() => {
|
connectors as SearchSourceConnector[]
|
||||||
// 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;
|
const isLoading = connectorsLoading || documentTypesLoading;
|
||||||
|
|
||||||
|
|
@ -163,8 +134,9 @@ export const ConnectorIndicator: FC = () => {
|
||||||
const activeConnectorsCount = connectors.length;
|
const activeConnectorsCount = connectors.length;
|
||||||
|
|
||||||
// Check which connectors are already connected
|
// Check which connectors are already connected
|
||||||
|
// Using Electric SQL + PGlite for real-time connector updates
|
||||||
const connectedTypes = new Set(
|
const connectedTypes = new Set(
|
||||||
(allConnectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!searchSpaceId) return null;
|
if (!searchSpaceId) return null;
|
||||||
|
|
@ -221,9 +193,8 @@ export const ConnectorIndicator: FC = () => {
|
||||||
<ConnectorAccountsListView
|
<ConnectorAccountsListView
|
||||||
connectorType={viewingAccountsType.connectorType}
|
connectorType={viewingAccountsType.connectorType}
|
||||||
connectorTitle={viewingAccountsType.connectorTitle}
|
connectorTitle={viewingAccountsType.connectorTitle}
|
||||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
connectors={(connectors || []) as SearchSourceConnector[]} // Using Electric SQL + PGlite for real-time connector updates (all connector types)
|
||||||
indexingConnectorIds={indexingConnectorIds}
|
indexingConnectorIds={indexingConnectorIds}
|
||||||
logsSummary={logsSummary}
|
|
||||||
onBack={handleBackFromAccountsList}
|
onBack={handleBackFromAccountsList}
|
||||||
onManage={handleStartEdit}
|
onManage={handleStartEdit}
|
||||||
onAddAccount={() => {
|
onAddAccount={() => {
|
||||||
|
|
@ -239,7 +210,7 @@ export const ConnectorIndicator: FC = () => {
|
||||||
) : connectingConnectorType ? (
|
) : connectingConnectorType ? (
|
||||||
<ConnectorConnectView
|
<ConnectorConnectView
|
||||||
connectorType={connectingConnectorType}
|
connectorType={connectingConnectorType}
|
||||||
onSubmit={handleSubmitConnectForm}
|
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
|
||||||
onBack={handleBackFromConnect}
|
onBack={handleBackFromConnect}
|
||||||
isSubmitting={isCreatingConnector}
|
isSubmitting={isCreatingConnector}
|
||||||
/>
|
/>
|
||||||
|
|
@ -263,13 +234,18 @@ export const ConnectorIndicator: FC = () => {
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||||
onFrequencyChange={setFrequencyMinutes}
|
onFrequencyChange={setFrequencyMinutes}
|
||||||
onSave={() => handleSaveConnector(() => refreshConnectors())}
|
onSave={() => {
|
||||||
|
startIndexing(editingConnector.id);
|
||||||
|
handleSaveConnector(() => refreshConnectors());
|
||||||
|
}}
|
||||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||||
onBack={handleBackFromEdit}
|
onBack={handleBackFromEdit}
|
||||||
onQuickIndex={
|
onQuickIndex={
|
||||||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||||
? () =>
|
? () => {
|
||||||
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type)
|
startIndexing(editingConnector.id);
|
||||||
|
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onConfigChange={setConnectorConfig}
|
onConfigChange={setConnectorConfig}
|
||||||
|
|
@ -296,7 +272,12 @@ export const ConnectorIndicator: FC = () => {
|
||||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||||
onFrequencyChange={setFrequencyMinutes}
|
onFrequencyChange={setFrequencyMinutes}
|
||||||
onConfigChange={setIndexingConnectorConfig}
|
onConfigChange={setIndexingConnectorConfig}
|
||||||
onStartIndexing={() => handleStartIndexing(() => refreshConnectors())}
|
onStartIndexing={() => {
|
||||||
|
if (indexingConfig.connectorId) {
|
||||||
|
startIndexing(indexingConfig.connectorId);
|
||||||
|
}
|
||||||
|
handleStartIndexing(() => refreshConnectors());
|
||||||
|
}}
|
||||||
onSkip={handleSkipIndexing}
|
onSkip={handleSkipIndexing}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -325,10 +306,9 @@ export const ConnectorIndicator: FC = () => {
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
connectedTypes={connectedTypes}
|
connectedTypes={connectedTypes}
|
||||||
connectingId={connectingId}
|
connectingId={connectingId}
|
||||||
allConnectors={allConnectors}
|
allConnectors={connectors}
|
||||||
documentTypeCounts={documentTypeCounts}
|
documentTypeCounts={documentTypeCounts}
|
||||||
indexingConnectorIds={indexingConnectorIds}
|
indexingConnectorIds={indexingConnectorIds}
|
||||||
logsSummary={logsSummary}
|
|
||||||
onConnectOAuth={handleConnectOAuth}
|
onConnectOAuth={handleConnectOAuth}
|
||||||
onConnectNonOAuth={handleConnectNonOAuth}
|
onConnectNonOAuth={handleConnectNonOAuth}
|
||||||
onCreateWebcrawler={handleCreateWebcrawler}
|
onCreateWebcrawler={handleCreateWebcrawler}
|
||||||
|
|
@ -345,7 +325,6 @@ export const ConnectorIndicator: FC = () => {
|
||||||
activeDocumentTypes={activeDocumentTypes}
|
activeDocumentTypes={activeDocumentTypes}
|
||||||
connectors={connectors as SearchSourceConnector[]}
|
connectors={connectors as SearchSourceConnector[]}
|
||||||
indexingConnectorIds={indexingConnectorIds}
|
indexingConnectorIds={indexingConnectorIds}
|
||||||
logsSummary={logsSummary}
|
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
onManage={handleStartEdit}
|
onManage={handleStartEdit}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
|
||||||
import { FileText, Loader2 } from "lucide-react";
|
import { FileText, Loader2 } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { LogActiveTask } from "@/contracts/types/log.types";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||||
import { ConnectorStatusBadge } from "./connector-status-badge";
|
import { ConnectorStatusBadge } from "./connector-status-badge";
|
||||||
|
|
@ -20,34 +18,11 @@ interface ConnectorCardProps {
|
||||||
isConnecting?: boolean;
|
isConnecting?: boolean;
|
||||||
documentCount?: number;
|
documentCount?: number;
|
||||||
accountCount?: number;
|
accountCount?: number;
|
||||||
lastIndexedAt?: string | null;
|
|
||||||
isIndexing?: boolean;
|
isIndexing?: boolean;
|
||||||
activeTask?: LogActiveTask;
|
|
||||||
onConnect?: () => void;
|
onConnect?: () => void;
|
||||||
onManage?: () => void;
|
onManage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a connector type is indexable (has documents)
|
|
||||||
* MCP connectors are tools only and don't have indexable content
|
|
||||||
*/
|
|
||||||
function isIndexableConnector(connectorType?: string): boolean {
|
|
||||||
if (!connectorType) return true; // Default to true for unknown types
|
|
||||||
const nonIndexableTypes = ["MCP_CONNECTOR"];
|
|
||||||
return !nonIndexableTypes.includes(connectorType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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")
|
* Format document count (e.g., "1.2k docs", "500 docs", "1.5M docs")
|
||||||
*/
|
*/
|
||||||
|
|
@ -62,45 +37,6 @@ function formatDocumentCount(count: number | undefined): string {
|
||||||
return `${m.replace(/\.0$/, "")}M docs`;
|
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> = ({
|
export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
|
@ -110,9 +46,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
isConnecting = false,
|
isConnecting = false,
|
||||||
documentCount,
|
documentCount,
|
||||||
accountCount,
|
accountCount,
|
||||||
lastIndexedAt,
|
|
||||||
isIndexing = false,
|
isIndexing = false,
|
||||||
activeTask,
|
|
||||||
onConnect,
|
onConnect,
|
||||||
onManage,
|
onManage,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -125,41 +59,11 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
const statusMessage = getConnectorStatusMessage(connectorType);
|
const statusMessage = getConnectorStatusMessage(connectorType);
|
||||||
const showWarnings = shouldShowWarnings();
|
const showWarnings = shouldShowWarnings();
|
||||||
|
|
||||||
// Extract count from active task message during indexing
|
|
||||||
const indexingCount = extractIndexedCount(activeTask?.message);
|
|
||||||
|
|
||||||
// Determine the status content to display
|
// Determine the status content to display
|
||||||
const getStatusContent = () => {
|
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) {
|
if (isConnected) {
|
||||||
// For non-indexable connectors (like MCP), show description instead of index status
|
// Don't show last indexed in overview tabs - only show in accounts list view
|
||||||
if (!isIndexableConnector(connectorType)) {
|
return null;
|
||||||
return description;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show last indexed date for connected indexable 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;
|
return description;
|
||||||
|
|
@ -201,9 +105,13 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
|
{isIndexing ? (
|
||||||
{isConnected && documentCount !== undefined && (
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5 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">
|
||||||
<span>{formatDocumentCount(documentCount)}</span>
|
<span>{formatDocumentCount(documentCount)}</span>
|
||||||
{accountCount !== undefined && accountCount > 0 && (
|
{accountCount !== undefined && accountCount > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -214,6 +122,8 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export const ConnectorStatusBadge: FC<ConnectorStatusBadgeProps> = ({
|
||||||
case "deprecated":
|
case "deprecated":
|
||||||
return {
|
return {
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
className: "ext-slate-500 dark:text-slate-400",
|
className: "text-slate-500 dark:text-slate-400",
|
||||||
defaultTitle: "Deprecated",
|
defaultTitle: "Deprecated",
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Connecting...
|
Connecting
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>{connectorType === "MCP_CONNECTOR" ? "Connect" : `Connect ${getConnectorTypeDisplay(connectorType)}`}</>
|
<>{connectorType === "MCP_CONNECTOR" ? "Connect" : `Connect ${getConnectorTypeDisplay(connectorType)}`}</>
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
{isQuickIndexing || isIndexing ? (
|
{isQuickIndexing || isIndexing ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Indexing...
|
Syncing
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -281,7 +281,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
{isDisconnecting ? (
|
{isDisconnecting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Disconnecting...
|
Disconnecting
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Confirm Disconnect"
|
"Confirm Disconnect"
|
||||||
|
|
|
||||||
|
|
@ -508,20 +508,23 @@ export const useConnectorDialog = () => {
|
||||||
|
|
||||||
// Handle submitting connect form
|
// Handle submitting connect form
|
||||||
const handleSubmitConnectForm = useCallback(
|
const handleSubmitConnectForm = useCallback(
|
||||||
async (formData: {
|
async (
|
||||||
name: string;
|
formData: {
|
||||||
connector_type: string;
|
name: string;
|
||||||
config: Record<string, unknown>;
|
connector_type: string;
|
||||||
is_indexable: boolean;
|
config: Record<string, unknown>;
|
||||||
last_indexed_at: null;
|
is_indexable: boolean;
|
||||||
periodic_indexing_enabled: boolean;
|
last_indexed_at: null;
|
||||||
indexing_frequency_minutes: number | null;
|
periodic_indexing_enabled: boolean;
|
||||||
next_scheduled_at: null;
|
indexing_frequency_minutes: number | null;
|
||||||
startDate?: Date;
|
next_scheduled_at: null;
|
||||||
endDate?: Date;
|
startDate?: Date;
|
||||||
periodicEnabled?: boolean;
|
endDate?: Date;
|
||||||
frequencyMinutes?: string;
|
periodicEnabled?: boolean;
|
||||||
}) => {
|
frequencyMinutes?: string;
|
||||||
|
},
|
||||||
|
onIndexingStart?: (connectorId: number) => void
|
||||||
|
) => {
|
||||||
if (!searchSpaceId || !connectingConnectorType) return;
|
if (!searchSpaceId || !connectingConnectorType) return;
|
||||||
|
|
||||||
// Prevent multiple submissions using ref for immediate check
|
// Prevent multiple submissions using ref for immediate check
|
||||||
|
|
@ -621,6 +624,11 @@ 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)
|
// Start indexing (backend will use defaults if dates are undefined)
|
||||||
const startDateStr = startDateForIndexing
|
const startDateStr = startDateForIndexing
|
||||||
? format(startDateForIndexing, "yyyy-MM-dd")
|
? format(startDateForIndexing, "yyyy-MM-dd")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
type ConnectorStatusConfig,
|
type ConnectorStatusConfig,
|
||||||
connectorStatusConfig,
|
connectorStatusConfig,
|
||||||
|
|
@ -14,34 +14,43 @@ export function useConnectorStatus() {
|
||||||
/**
|
/**
|
||||||
* Get status configuration for a specific connector type
|
* Get status configuration for a specific connector type
|
||||||
*/
|
*/
|
||||||
const getConnectorStatus = (connectorType: string | undefined): ConnectorStatusConfig => {
|
const getConnectorStatus = useCallback(
|
||||||
if (!connectorType) {
|
(connectorType: string | undefined): ConnectorStatusConfig => {
|
||||||
return getDefaultConnectorStatus();
|
if (!connectorType) {
|
||||||
}
|
return getDefaultConnectorStatus();
|
||||||
|
}
|
||||||
|
|
||||||
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
|
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
|
||||||
};
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a connector is enabled
|
* Check if a connector is enabled
|
||||||
*/
|
*/
|
||||||
const isConnectorEnabled = (connectorType: string | undefined): boolean => {
|
const isConnectorEnabled = useCallback(
|
||||||
return getConnectorStatus(connectorType).enabled;
|
(connectorType: string | undefined): boolean => {
|
||||||
};
|
return getConnectorStatus(connectorType).enabled;
|
||||||
|
},
|
||||||
|
[getConnectorStatus]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get status message for a connector
|
* Get status message for a connector
|
||||||
*/
|
*/
|
||||||
const getConnectorStatusMessage = (connectorType: string | undefined): string | null => {
|
const getConnectorStatusMessage = useCallback(
|
||||||
return getConnectorStatus(connectorType).statusMessage || null;
|
(connectorType: string | undefined): string | null => {
|
||||||
};
|
return getConnectorStatus(connectorType).statusMessage || null;
|
||||||
|
},
|
||||||
|
[getConnectorStatus]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if warnings should be shown globally
|
* Check if warnings should be shown globally
|
||||||
*/
|
*/
|
||||||
const shouldShowWarnings = (): boolean => {
|
const shouldShowWarnings = useCallback((): boolean => {
|
||||||
return connectorStatusConfig.globalSettings.showWarnings;
|
return connectorStatusConfig.globalSettings.showWarnings;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -50,6 +59,6 @@ export function useConnectorStatus() {
|
||||||
getConnectorStatusMessage,
|
getConnectorStatusMessage,
|
||||||
shouldShowWarnings,
|
shouldShowWarnings,
|
||||||
}),
|
}),
|
||||||
[]
|
[getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
|
||||||
import { ArrowRight, Cable, Loader2 } from "lucide-react";
|
import { ArrowRight, Cable, Loader2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
@ -24,7 +23,6 @@ interface ActiveConnectorsTabProps {
|
||||||
activeDocumentTypes: Array<[string, number]>;
|
activeDocumentTypes: Array<[string, number]>;
|
||||||
connectors: SearchSourceConnector[];
|
connectors: SearchSourceConnector[];
|
||||||
indexingConnectorIds: Set<number>;
|
indexingConnectorIds: Set<number>;
|
||||||
logsSummary: LogSummary | undefined;
|
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
onTabChange: (value: string) => void;
|
onTabChange: (value: string) => void;
|
||||||
onManage?: (connector: SearchSourceConnector) => void;
|
onManage?: (connector: SearchSourceConnector) => void;
|
||||||
|
|
@ -45,7 +43,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
activeDocumentTypes,
|
activeDocumentTypes,
|
||||||
connectors,
|
connectors,
|
||||||
indexingConnectorIds,
|
indexingConnectorIds,
|
||||||
logsSummary,
|
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
onManage,
|
onManage,
|
||||||
|
|
@ -78,32 +75,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
return `${m.replace(/\.0$/, "")}M docs`;
|
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)
|
// Document types that should be shown as standalone cards (not from connectors)
|
||||||
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
|
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
|
||||||
|
|
||||||
|
|
@ -202,24 +173,25 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
const documentCount = getDocumentCountForConnector(
|
const documentCount = getDocumentCountForConnector(
|
||||||
connectorType,
|
connectorType,
|
||||||
documentTypeCounts
|
documentTypeCounts
|
||||||
);
|
);
|
||||||
const accountCount = typeConnectors.length;
|
const accountCount = typeConnectors.length;
|
||||||
const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
|
|
||||||
const handleManageClick = () => {
|
const handleManageClick = () => {
|
||||||
if (onViewAccountsList) {
|
if (onViewAccountsList) {
|
||||||
onViewAccountsList(connectorType, title);
|
onViewAccountsList(connectorType, title);
|
||||||
} else if (onManage && typeConnectors[0]) {
|
} else if (onManage && typeConnectors[0]) {
|
||||||
onManage(typeConnectors[0]);
|
onManage(typeConnectors[0]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`oauth-type-${connectorType}`}
|
key={`oauth-type-${connectorType}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
|
"relative flex items-center gap-4 p-4 rounded-xl transition-all",
|
||||||
isAnyIndexing
|
isAnyIndexing
|
||||||
? "bg-primary/5 border-primary/20"
|
? "bg-primary/5 border-0"
|
||||||
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -237,30 +209,17 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
{isAnyIndexing ? (
|
{isAnyIndexing ? (
|
||||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
Indexing...
|
Syncing
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||||
{isIndexableConnector(connectorType) && (
|
<span>{formatDocumentCount(documentCount)}</span>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
|
<span className="text-muted-foreground/50">•</span>
|
||||||
{mostRecentLastIndexed
|
<span>
|
||||||
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
|
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
|
||||||
: "Never indexed"}
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
|
|
||||||
{isIndexableConnector(connectorType) && (
|
|
||||||
<>
|
|
||||||
<span>{formatDocumentCount(documentCount)}</span>
|
|
||||||
<span className="text-muted-foreground/50">•</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -277,9 +236,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
{/* Non-OAuth Connectors - Individual Cards */}
|
{/* Non-OAuth Connectors - Individual Cards */}
|
||||||
{filteredNonOAuthConnectors.map((connector) => {
|
{filteredNonOAuthConnectors.map((connector) => {
|
||||||
const isIndexing = indexingConnectorIds.has(connector.id);
|
const isIndexing = indexingConnectorIds.has(connector.id);
|
||||||
const activeTask = logsSummary?.active_tasks?.find(
|
|
||||||
(task: LogActiveTask) => task.connector_id === connector.id
|
|
||||||
);
|
|
||||||
const documentCount = getDocumentCountForConnector(
|
const documentCount = getDocumentCountForConnector(
|
||||||
connector.connector_type,
|
connector.connector_type,
|
||||||
documentTypeCounts
|
documentTypeCounts
|
||||||
|
|
@ -288,10 +244,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
<div
|
<div
|
||||||
key={`connector-${connector.id}`}
|
key={`connector-${connector.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
|
"flex items-center gap-4 p-4 rounded-xl transition-all",
|
||||||
isIndexing
|
isIndexing
|
||||||
? "bg-primary/5 border-primary/20"
|
? "bg-primary/5 border-0"
|
||||||
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -313,26 +269,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
{isIndexing ? (
|
{isIndexing ? (
|
||||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
Indexing...
|
Syncing
|
||||||
{activeTask?.message && (
|
|
||||||
<span className="text-muted-foreground truncate max-w-[150px]">
|
|
||||||
• {activeTask.message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
{isIndexableConnector(connector.connector_type)
|
|
||||||
? connector.last_indexed_at
|
|
||||||
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
|
|
||||||
: "Never indexed"
|
|
||||||
: connector.connector_type === "MCP_CONNECTOR"
|
|
||||||
? ""
|
|
||||||
: "Active"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isIndexableConnector(connector.connector_type) && (
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
|
||||||
{formatDocumentCount(documentCount)}
|
{formatDocumentCount(documentCount)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -389,19 +329,12 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<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">
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||||
<Cable className="size-8 text-muted-foreground/50" />
|
<Cable className="size-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-lg font-semibold">No active sources</h4>
|
<h4 className="text-lg font-semibold">No active sources</h4>
|
||||||
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">
|
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">
|
||||||
Connect your first service to start searching across all your data.
|
Connect your first service to start searching across all your data.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="mt-6 text-primary hover:underline"
|
|
||||||
onClick={() => onTabChange("all")}
|
|
||||||
>
|
|
||||||
Browse available connectors
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
|
||||||
import { ConnectorCard } from "../components/connector-card";
|
import { ConnectorCard } from "../components/connector-card";
|
||||||
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
||||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||||
|
|
@ -30,7 +27,6 @@ interface AllConnectorsTabProps {
|
||||||
allConnectors: SearchSourceConnector[] | undefined;
|
allConnectors: SearchSourceConnector[] | undefined;
|
||||||
documentTypeCounts?: Record<string, number>;
|
documentTypeCounts?: Record<string, number>;
|
||||||
indexingConnectorIds?: Set<number>;
|
indexingConnectorIds?: Set<number>;
|
||||||
logsSummary?: LogSummary;
|
|
||||||
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
|
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
|
||||||
onConnectNonOAuth?: (connectorType: string) => void;
|
onConnectNonOAuth?: (connectorType: string) => void;
|
||||||
onCreateWebcrawler?: () => void;
|
onCreateWebcrawler?: () => void;
|
||||||
|
|
@ -41,13 +37,11 @@ interface AllConnectorsTabProps {
|
||||||
|
|
||||||
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
searchSpaceId,
|
|
||||||
connectedTypes,
|
connectedTypes,
|
||||||
connectingId,
|
connectingId,
|
||||||
allConnectors,
|
allConnectors,
|
||||||
documentTypeCounts,
|
documentTypeCounts,
|
||||||
indexingConnectorIds,
|
indexingConnectorIds,
|
||||||
logsSummary,
|
|
||||||
onConnectOAuth,
|
onConnectOAuth,
|
||||||
onConnectNonOAuth,
|
onConnectNonOAuth,
|
||||||
onCreateWebcrawler,
|
onCreateWebcrawler,
|
||||||
|
|
@ -55,14 +49,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
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
|
// Filter connectors based on search
|
||||||
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
|
|
@ -125,11 +111,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
// Check if any account is currently indexing
|
// Check if any account is currently indexing
|
||||||
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
|
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 (
|
return (
|
||||||
<ConnectorCard
|
<ConnectorCard
|
||||||
key={connector.id}
|
key={connector.id}
|
||||||
|
|
@ -143,7 +124,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
accountCount={accountCount}
|
accountCount={accountCount}
|
||||||
lastIndexedAt={mostRecentLastIndexed}
|
lastIndexedAt={mostRecentLastIndexed}
|
||||||
isIndexing={isIndexing}
|
isIndexing={isIndexing}
|
||||||
activeTask={activeTask}
|
|
||||||
onConnect={() => onConnectOAuth(connector)}
|
onConnect={() => onConnectOAuth(connector)}
|
||||||
onManage={
|
onManage={
|
||||||
isConnected && onViewAccountsList
|
isConnected && onViewAccountsList
|
||||||
|
|
@ -181,9 +161,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
documentTypeCounts
|
documentTypeCounts
|
||||||
);
|
);
|
||||||
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
|
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
|
||||||
const activeTask = actualConnector
|
|
||||||
? getActiveTaskForConnector(actualConnector.id)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const handleConnect = onConnectNonOAuth
|
const handleConnect = onConnectNonOAuth
|
||||||
? () => onConnectNonOAuth(connector.connectorType)
|
? () => onConnectNonOAuth(connector.connectorType)
|
||||||
|
|
@ -199,9 +176,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
isConnecting={isConnecting}
|
isConnecting={isConnecting}
|
||||||
documentCount={documentCount}
|
documentCount={documentCount}
|
||||||
lastIndexedAt={actualConnector?.last_indexed_at}
|
|
||||||
isIndexing={isIndexing}
|
isIndexing={isIndexing}
|
||||||
activeTask={activeTask}
|
|
||||||
onConnect={handleConnect}
|
onConnect={handleConnect}
|
||||||
onManage={
|
onManage={
|
||||||
actualConnector && onManage ? () => onManage(actualConnector) : undefined
|
actualConnector && onManage ? () => onManage(actualConnector) : undefined
|
||||||
|
|
@ -242,9 +217,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
? getDocumentCountForConnector(crawler.connectorType, documentTypeCounts)
|
? getDocumentCountForConnector(crawler.connectorType, documentTypeCounts)
|
||||||
: undefined;
|
: undefined;
|
||||||
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
|
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
|
||||||
const activeTask = actualConnector
|
|
||||||
? getActiveTaskForConnector(actualConnector.id)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const handleConnect =
|
const handleConnect =
|
||||||
isYouTube && onCreateYouTubeCrawler
|
isYouTube && onCreateYouTubeCrawler
|
||||||
|
|
@ -269,9 +241,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
isConnecting={isConnecting}
|
isConnecting={isConnecting}
|
||||||
documentCount={documentCount}
|
documentCount={documentCount}
|
||||||
lastIndexedAt={actualConnector?.last_indexed_at}
|
|
||||||
isIndexing={isIndexing}
|
isIndexing={isIndexing}
|
||||||
activeTask={activeTask}
|
|
||||||
onConnect={handleConnect}
|
onConnect={handleConnect}
|
||||||
onManage={
|
onManage={
|
||||||
actualConnector && onManage ? () => onManage(actualConnector) : undefined
|
actualConnector && onManage ? () => onManage(actualConnector) : undefined
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
|
||||||
|
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared MCP configuration validation result
|
||||||
|
*/
|
||||||
|
export interface MCPConfigValidationResult {
|
||||||
|
config: MCPServerConfig | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared MCP connection test result
|
||||||
|
*/
|
||||||
|
export interface MCPConnectionTestResult {
|
||||||
|
status: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
tools: MCPToolDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate MCP server configuration from JSON string
|
||||||
|
* @param configJson - JSON string containing MCP server configuration
|
||||||
|
* @returns Validation result with parsed config or error message
|
||||||
|
*/
|
||||||
|
export const parseMCPConfig = (configJson: string): MCPConfigValidationResult => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configJson);
|
||||||
|
|
||||||
|
// Validate that it's an object, not an array
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
error: "Please provide a single server configuration object, not an array",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!parsed.command || typeof parsed.command !== "string") {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
error: "'command' field is required and must be a string",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: MCPServerConfig = {
|
||||||
|
command: parsed.command,
|
||||||
|
args: parsed.args || [],
|
||||||
|
env: parsed.env || {},
|
||||||
|
transport: parsed.transport || "stdio",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
error: error instanceof Error ? error.message : "Invalid JSON",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to MCP server
|
||||||
|
* @param serverConfig - MCP server configuration to test
|
||||||
|
* @returns Connection test result with status, message, and available tools
|
||||||
|
*/
|
||||||
|
export const testMCPConnection = async (
|
||||||
|
serverConfig: MCPServerConfig
|
||||||
|
): Promise<MCPConnectionTestResult> => {
|
||||||
|
try {
|
||||||
|
const result = await connectorsApiService.testMCPConnection(serverConfig);
|
||||||
|
|
||||||
|
if (result.status === "success") {
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
message: `Successfully connected. Found ${result.tools.length} tool${result.tools.length !== 1 ? "s" : ""}.`,
|
||||||
|
tools: result.tools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: result.message || "Failed to connect",
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : "Failed to connect",
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract server name from MCP config JSON
|
||||||
|
* @param configJson - JSON string containing MCP server configuration
|
||||||
|
* @returns Server name if found, otherwise default name
|
||||||
|
*/
|
||||||
|
export const extractServerName = (configJson: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configJson);
|
||||||
|
if (parsed.name && typeof parsed.name === "string") {
|
||||||
|
return parsed.name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Return default if parsing fails
|
||||||
|
}
|
||||||
|
return "MCP Server";
|
||||||
|
};
|
||||||
|
|
@ -6,7 +6,6 @@ import type { FC } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||||
|
|
@ -16,7 +15,6 @@ interface ConnectorAccountsListViewProps {
|
||||||
connectorTitle: string;
|
connectorTitle: string;
|
||||||
connectors: SearchSourceConnector[];
|
connectors: SearchSourceConnector[];
|
||||||
indexingConnectorIds: Set<number>;
|
indexingConnectorIds: Set<number>;
|
||||||
logsSummary: LogSummary | undefined;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onManage: (connector: SearchSourceConnector) => void;
|
onManage: (connector: SearchSourceConnector) => void;
|
||||||
onAddAccount: () => void;
|
onAddAccount: () => void;
|
||||||
|
|
@ -68,7 +66,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
connectorTitle,
|
connectorTitle,
|
||||||
connectors,
|
connectors,
|
||||||
indexingConnectorIds,
|
indexingConnectorIds,
|
||||||
logsSummary,
|
|
||||||
onBack,
|
onBack,
|
||||||
onManage,
|
onManage,
|
||||||
onAddAccount,
|
onAddAccount,
|
||||||
|
|
@ -133,7 +130,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] sm:text-[12px] font-medium">
|
<span className="text-[11px] sm:text-[12px] font-medium">
|
||||||
{isConnecting ? "Connecting..." : "Add Account"}
|
{isConnecting ? "Connecting" : "Add Account"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,18 +142,15 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{typeConnectors.map((connector) => {
|
{typeConnectors.map((connector) => {
|
||||||
const isIndexing = indexingConnectorIds.has(connector.id);
|
const isIndexing = indexingConnectorIds.has(connector.id);
|
||||||
const activeTask = logsSummary?.active_tasks?.find(
|
|
||||||
(task: LogActiveTask) => task.connector_id === connector.id
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={connector.id}
|
key={connector.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
|
"flex items-center gap-4 p-4 rounded-xl transition-all",
|
||||||
isIndexing
|
isIndexing
|
||||||
? "bg-primary/5 border-primary/20"
|
? "bg-primary/5 border-0"
|
||||||
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -176,12 +170,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
{isIndexing ? (
|
{isIndexing ? (
|
||||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
Indexing...
|
Syncing
|
||||||
{activeTask?.message && (
|
|
||||||
<span className="text-muted-foreground truncate max-w-[100px]">
|
|
||||||
• {activeTask.message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
|
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type FC,
|
type FC,
|
||||||
|
|
@ -85,13 +84,11 @@ const DocumentUploadPopupContent: FC<{
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}> = ({ isOpen, onOpenChange }) => {
|
}> = ({ isOpen, onOpenChange }) => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (!searchSpaceId) return null;
|
if (!searchSpaceId) return null;
|
||||||
|
|
||||||
const handleSuccess = () => {
|
const handleSuccess = () => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -124,14 +124,23 @@ const ThreadScrollToBottom: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimeBasedGreeting = (userEmail?: string): string => {
|
const getTimeBasedGreeting = (user?: { display_name?: string | null; email?: string }): string => {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
|
|
||||||
// Extract first name from email if available
|
// Extract first name: prefer display_name, fall back to email extraction
|
||||||
const firstName = userEmail
|
let firstName: string | null = null;
|
||||||
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
|
||||||
userEmail.split("@")[0].split(".")[0].slice(1)
|
if (user?.display_name?.trim()) {
|
||||||
: null;
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
// Array of greeting variations for each time period
|
// Array of greeting variations for each time period
|
||||||
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
|
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
|
||||||
|
|
@ -172,7 +181,7 @@ const ThreadWelcome: FC = () => {
|
||||||
const { data: user } = useAtomValue(currentUserAtom);
|
const { data: user } = useAtomValue(currentUserAtom);
|
||||||
|
|
||||||
// Memoize greeting so it doesn't change on re-renders (only on user change)
|
// Memoize greeting so it doesn't change on re-renders (only on user change)
|
||||||
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
|
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,7 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
|
||||||
<span className="aui-sr-only sr-only">{tooltip}</span>
|
<span className="aui-sr-only sr-only">{tooltip}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent side={side}>{tooltip}</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>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ function GetStartedButton() {
|
||||||
return (
|
return (
|
||||||
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
|
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/register"
|
||||||
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"
|
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
|
Get Started
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||||
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
||||||
|
|
@ -278,10 +279,19 @@ export function LayoutDataProvider({
|
||||||
router.push(`/dashboard/${searchSpaceId}/team`);
|
router.push(`/dashboard/${searchSpaceId}/team`);
|
||||||
}, [router, searchSpaceId]);
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
trackLogout();
|
trackLogout();
|
||||||
resetUser();
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("surfsense_bearer_token");
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
router.push("/");
|
router.push("/");
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { Moon, Sun } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { NotificationButton } from "@/components/notifications/NotificationButton";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
breadcrumb?: React.ReactNode;
|
breadcrumb?: React.ReactNode;
|
||||||
|
|
@ -29,6 +30,9 @@ export function Header({
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Notifications */}
|
||||||
|
<NotificationButton />
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
{onToggleTheme && (
|
{onToggleTheme && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
fetchThreads,
|
fetchThreads,
|
||||||
searchThreads,
|
searchThreads,
|
||||||
type ThreadListItem,
|
|
||||||
updateThread,
|
updateThread,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -410,7 +409,7 @@ export function AllPrivateChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : isSearchMode ? (
|
) : isSearchMode ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("no_chats_found") || "No chats found"}
|
{t("no_chats_found") || "No chats found"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -420,7 +419,7 @@ export function AllPrivateChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Lock className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
<Lock className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{showArchived
|
{showArchived
|
||||||
? t("no_archived_chats") || "No archived chats"
|
? t("no_archived_chats") || "No archived chats"
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
fetchThreads,
|
fetchThreads,
|
||||||
searchThreads,
|
searchThreads,
|
||||||
type ThreadListItem,
|
|
||||||
updateThread,
|
updateThread,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -410,7 +409,7 @@ export function AllSharedChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : isSearchMode ? (
|
) : isSearchMode ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("no_chats_found") || "No chats found"}
|
{t("no_chats_found") || "No chats found"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -420,7 +419,7 @@ export function AllSharedChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Users className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{showArchived
|
{showArchived
|
||||||
? t("no_archived_chats") || "No archived chats"
|
? t("no_archived_chats") || "No archived chats"
|
||||||
|
|
|
||||||
|
|
@ -156,10 +156,10 @@ export function Sidebar({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-5 w-5"
|
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||||
onClick={onViewAllSharedChats}
|
onClick={onViewAllSharedChats}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
|
|
@ -197,10 +197,10 @@ export function Sidebar({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-5 w-5"
|
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||||
onClick={onViewAllPrivateChats}
|
onClick={onViewAllPrivateChats}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,14 @@ export function SidebarSection({
|
||||||
|
|
||||||
{/* Action button - visible on hover (always visible on mobile) */}
|
{/* Action button - visible on hover (always visible on mobile) */}
|
||||||
{action && (
|
{action && (
|
||||||
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center gap-0.5">
|
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Persistent action - always visible */}
|
{/* Persistent action - always visible */}
|
||||||
{persistentAction && (
|
{persistentAction && (
|
||||||
<div className="shrink-0 pr-1 flex items-center gap-0.5">{persistentAction}</div>
|
<div className="shrink-0 pr-1 flex items-center">{persistentAction}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Globe, Loader2, Lock, Share2, Users } from "lucide-react";
|
import { Loader2, Lock, Share2, Users } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -92,8 +92,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 md:h-9 gap-1.5 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl",
|
"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",
|
||||||
"border border-border/80 bg-background/50 backdrop-blur-sm",
|
|
||||||
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
|
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
|
||||||
"text-xs md:text-sm font-medium text-foreground",
|
"text-xs md:text-sm font-medium text-foreground",
|
||||||
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||||
|
|
@ -104,7 +103,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<span className="hidden md:inline">
|
<span className="hidden md:inline">
|
||||||
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
|
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
|
||||||
</span>
|
</span>
|
||||||
<Share2 className="size-3 md:size-3.5 text-muted-foreground" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
|
@ -113,25 +111,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
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">
|
<div className="p-1.5 space-y-1">
|
||||||
{/* Updating overlay */}
|
{/* Updating overlay */}
|
||||||
{isUpdating && (
|
{isUpdating && (
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
<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">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
<span>Updating...</span>
|
<span>Updating</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -149,8 +135,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
"focus:outline-none focus:ring-2 focus:ring-primary/20",
|
"focus:outline-none",
|
||||||
isSelected && "bg-accent/80 ring-1 ring-primary/20"
|
isSelected && "bg-accent/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -185,18 +171,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
</div>
|
</div>
|
||||||
) : actualDocuments.length === 0 ? (
|
) : actualDocuments.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
|
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
|
||||||
<FileText className="h-5 w-5 text-muted-foreground/50 mb-1" />
|
<FileText className="h-5 w-5 text-muted-foreground/55 mb-1" />
|
||||||
<p className="text-sm text-muted-foreground">No documents found</p>
|
<p className="text-sm text-muted-foreground">No documents found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -337,7 +337,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
{/* SurfSense Documentation Section */}
|
{/* SurfSense Documentation Section */}
|
||||||
{surfsenseDocsList.length > 0 && (
|
{surfsenseDocsList.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<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">
|
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
||||||
SurfSense Docs
|
SurfSense Docs
|
||||||
</div>
|
</div>
|
||||||
{surfsenseDocsList.map((doc) => {
|
{surfsenseDocsList.map((doc) => {
|
||||||
|
|
@ -385,7 +385,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
{/* User Documents Section */}
|
{/* User Documents Section */}
|
||||||
{userDocsList.length > 0 && (
|
{userDocsList.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<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">
|
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
||||||
Your Documents
|
Your Documents
|
||||||
</div>
|
</div>
|
||||||
{userDocsList.map((doc) => {
|
{userDocsList.map((doc) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
surfsense_web/components/notifications/NotificationPopup.tsx
Normal file
127
surfsense_web/components/notifications/NotificationPopup.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
surfsense_web/components/providers/ElectricProvider.tsx
Normal file
132
surfsense_web/components/providers/ElectricProvider.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"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>;
|
||||||
|
}
|
||||||
|
|
@ -560,7 +560,7 @@ export function LLMConfigForm({
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" />
|
||||||
{mode === "edit" ? "Updating..." : "Creating..."}
|
{mode === "edit" ? "Updating..." : "Creating"}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
|
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
|
|
@ -121,7 +120,6 @@ export function DocumentUploadTab({
|
||||||
onAccordionStateChange,
|
onAccordionStateChange,
|
||||||
}: DocumentUploadTabProps) {
|
}: DocumentUploadTabProps) {
|
||||||
const t = useTranslations("upload_documents");
|
const t = useTranslations("upload_documents");
|
||||||
const router = useRouter();
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [accordionValue, setAccordionValue] = useState<string>("");
|
const [accordionValue, setAccordionValue] = useState<string>("");
|
||||||
|
|
@ -224,7 +222,7 @@ export function DocumentUploadTab({
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
trackDocumentUploadSuccess(Number(searchSpaceId), files.length);
|
trackDocumentUploadSuccess(Number(searchSpaceId), files.length);
|
||||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||||
onSuccess?.() || router.push(`/dashboard/${searchSpaceId}/documents`);
|
onSuccess?.();
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ function PodcastGeneratingState({ title }: { title: string }) {
|
||||||
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
|
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
|
||||||
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
|
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
<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>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ function TooltipContent({
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,5 @@
|
||||||
"elasticsearch",
|
"elasticsearch",
|
||||||
"bookstack"
|
"bookstack"
|
||||||
],
|
],
|
||||||
"defaultOpen": true
|
"defaultOpen": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
275
surfsense_web/content/docs/how-to/electric-sql.mdx
Normal file
275
surfsense_web/content/docs/how-to/electric-sql.mdx
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
---
|
||||||
|
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
|
||||||
|
|
||||||
|
Ensure your environment files are configured. If you haven't set up SurfSense yet, follow the [Manual Installation Guide](/docs/manual-installation) first.
|
||||||
|
|
||||||
|
For Electric SQL, verify these variables are set:
|
||||||
|
|
||||||
|
**Root `.env`:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ELECTRIC_PORT=5133
|
||||||
|
POSTGRES_HOST=host.docker.internal # Use 'db' for Docker PostgreSQL instance
|
||||||
|
ELECTRIC_DB_USER=electric
|
||||||
|
ELECTRIC_DB_PASSWORD=electric_password
|
||||||
|
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend `.env` (`surfsense_web/.env`):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
|
||||||
|
NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option A: Using Docker PostgreSQL
|
||||||
|
|
||||||
|
If you're using the Docker-managed PostgreSQL instance, follow these steps:
|
||||||
|
|
||||||
|
**1. Update environment variable:**
|
||||||
|
|
||||||
|
In your root `.env` file, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Start PostgreSQL and Electric SQL:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d db electric
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run database migration:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd surfsense_backend
|
||||||
|
uv run alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 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` | Root `.env` | Port to expose Electric SQL | `5133` |
|
||||||
|
| `POSTGRES_HOST` | Root `.env` | PostgreSQL host (`db` for Docker, `host.docker.internal` for local) | `host.docker.internal` |
|
||||||
|
| `ELECTRIC_DB_USER` | Root `.env` | Database user for Electric | `electric` |
|
||||||
|
| `ELECTRIC_DB_PASSWORD` | Root `.env` | Database password for Electric | `electric_password` |
|
||||||
|
| `NEXT_PUBLIC_ELECTRIC_URL` | Frontend `.env` | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` |
|
||||||
|
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend `.env` | Authentication mode (`insecure` for dev, `secure` for production) | `insecure` |
|
||||||
|
## Verify Setup
|
||||||
|
|
||||||
|
To verify Electric SQL is running correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5133/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
You should receive:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"active"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
5
surfsense_web/content/docs/how-to/meta.json
Normal file
5
surfsense_web/content/docs/how-to/meta.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"title": "How to",
|
||||||
|
"pages": ["electric-sql"],
|
||||||
|
"defaultOpen": false
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"installation",
|
"installation",
|
||||||
"docker-installation",
|
"docker-installation",
|
||||||
"manual-installation",
|
"manual-installation",
|
||||||
"connectors"
|
"connectors",
|
||||||
|
"how-to"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export enum EnumConnectorName {
|
export enum EnumConnectorName {
|
||||||
|
SERPER_API = "SERPER_API",
|
||||||
TAVILY_API = "TAVILY_API",
|
TAVILY_API = "TAVILY_API",
|
||||||
SEARXNG_API = "SEARXNG_API",
|
SEARXNG_API = "SEARXNG_API",
|
||||||
LINKUP_API = "LINKUP_API",
|
LINKUP_API = "LINKUP_API",
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@ export const documentTypeEnum = z.enum([
|
||||||
"CRAWLED_URL",
|
"CRAWLED_URL",
|
||||||
"FILE",
|
"FILE",
|
||||||
"SLACK_CONNECTOR",
|
"SLACK_CONNECTOR",
|
||||||
|
"TEAMS_CONNECTOR",
|
||||||
"NOTION_CONNECTOR",
|
"NOTION_CONNECTOR",
|
||||||
"YOUTUBE_VIDEO",
|
"YOUTUBE_VIDEO",
|
||||||
"GITHUB_CONNECTOR",
|
"GITHUB_CONNECTOR",
|
||||||
|
"LINEAR_CONNECTOR",
|
||||||
"DISCORD_CONNECTOR",
|
"DISCORD_CONNECTOR",
|
||||||
"JIRA_CONNECTOR",
|
"JIRA_CONNECTOR",
|
||||||
"CONFLUENCE_CONNECTOR",
|
"CONFLUENCE_CONNECTOR",
|
||||||
|
|
@ -19,10 +21,10 @@ export const documentTypeEnum = z.enum([
|
||||||
"AIRTABLE_CONNECTOR",
|
"AIRTABLE_CONNECTOR",
|
||||||
"LUMA_CONNECTOR",
|
"LUMA_CONNECTOR",
|
||||||
"ELASTICSEARCH_CONNECTOR",
|
"ELASTICSEARCH_CONNECTOR",
|
||||||
"LINEAR_CONNECTOR",
|
"BOOKSTACK_CONNECTOR",
|
||||||
"NOTE",
|
|
||||||
"CIRCLEBACK",
|
"CIRCLEBACK",
|
||||||
"SURFSENSE_DOCS",
|
"SURFSENSE_DOCS",
|
||||||
|
"NOTE",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const document = z.object({
|
export const document = z.object({
|
||||||
|
|
|
||||||
120
surfsense_web/contracts/types/notification.types.ts
Normal file
120
surfsense_web/contracts/types/notification.types.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
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>;
|
||||||
203
surfsense_web/hooks/use-connectors-electric.ts
Normal file
203
surfsense_web/hooks/use-connectors-electric.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
185
surfsense_web/hooks/use-documents-electric.ts
Normal file
185
surfsense_web/hooks/use-documents-electric.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
248
surfsense_web/hooks/use-notifications.ts
Normal file
248
surfsense_web/hooks/use-notifications.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
surfsense_web/lib/electric/auth.ts
Normal file
20
surfsense_web/lib/electric/auth.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* 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 "";
|
||||||
|
}
|
||||||
665
surfsense_web/lib/electric/client.ts
Normal file
665
surfsense_web/lib/electric/client.ts
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
36
surfsense_web/lib/electric/context.ts
Normal file
36
surfsense_web/lib/electric/context.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
"description_label": "Description",
|
"description_label": "Description",
|
||||||
"description_placeholder": "What is this search space for?",
|
"description_placeholder": "What is this search space for?",
|
||||||
"create_button": "Create",
|
"create_button": "Create",
|
||||||
"creating": "Creating...",
|
"creating": "Creating",
|
||||||
"all_search_spaces": "All Search Spaces",
|
"all_search_spaces": "All Search Spaces",
|
||||||
"search_spaces_count": "{count, plural, =0 {No search spaces} =1 {1 search space} other {# 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",
|
"no_search_spaces": "No search spaces yet",
|
||||||
|
|
@ -658,7 +658,7 @@
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chats": "Private Chats",
|
"chats": "Private Chats",
|
||||||
"shared_chats": "Shared Chats",
|
"shared_chats": "Shared Chats",
|
||||||
"search_chats": "Search chats...",
|
"search_chats": "Search chats",
|
||||||
"no_chats_found": "No chats found",
|
"no_chats_found": "No chats found",
|
||||||
"no_shared_chats": "No shared chats",
|
"no_shared_chats": "No shared chats",
|
||||||
"view_all_shared_chats": "View all shared chats",
|
"view_all_shared_chats": "View all shared chats",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@
|
||||||
"@blocknote/mantine": "^0.45.0",
|
"@blocknote/mantine": "^0.45.0",
|
||||||
"@blocknote/react": "^0.45.0",
|
"@blocknote/react": "^0.45.0",
|
||||||
"@blocknote/server-util": "^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",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@number-flow/react": "^0.5.10",
|
"@number-flow/react": "^0.5.10",
|
||||||
"@posthog/react": "^1.5.2",
|
"@posthog/react": "^1.5.2",
|
||||||
|
|
@ -120,6 +124,7 @@
|
||||||
"eslint-config-next": "15.2.0",
|
"eslint-config-next": "15.2.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
673
surfsense_web/pnpm-lock.yaml
generated
673
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue