mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 13:52:40 +02:00
Merge pull request #866 from AnishSarkar22/fix/docker-dev
fix: enhance docker build CI pipeline, update docker ports & docker docs
This commit is contained in:
commit
d41d1a1c7f
90 changed files with 2481 additions and 2054 deletions
|
|
@ -1,97 +0,0 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
LICENSE
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.cursor/
|
||||
|
||||
# Node
|
||||
**/node_modules/
|
||||
**/.next/
|
||||
**/dist/
|
||||
**/.turbo/
|
||||
**/.cache/
|
||||
**/coverage/
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.pyd
|
||||
**/.Python
|
||||
**/build/
|
||||
**/develop-eggs/
|
||||
**/downloads/
|
||||
**/eggs/
|
||||
**/.eggs/
|
||||
# Python venv lib folders (but not frontend lib folders)
|
||||
surfsense_backend/lib/
|
||||
surfsense_backend/lib64/
|
||||
**/parts/
|
||||
**/sdist/
|
||||
**/var/
|
||||
**/wheels/
|
||||
**/*.egg-info/
|
||||
**/.installed.cfg
|
||||
**/*.egg
|
||||
**/pip-log.txt
|
||||
**/.tox/
|
||||
**/.coverage
|
||||
**/htmlcov/
|
||||
**/.pytest_cache/
|
||||
**/nosetests.xml
|
||||
**/coverage.xml
|
||||
|
||||
# Environment
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/*.local
|
||||
|
||||
# Docker
|
||||
**/Dockerfile
|
||||
**/docker-compose*.yml
|
||||
**/.docker/
|
||||
|
||||
# Testing
|
||||
**/tests/
|
||||
**/test/
|
||||
**/__tests__/
|
||||
**/*.test.*
|
||||
**/*.spec.*
|
||||
|
||||
# Logs
|
||||
**/*.log
|
||||
|
||||
# Temporary files
|
||||
**/tmp/
|
||||
**/temp/
|
||||
**/.tmp/
|
||||
**/.temp/
|
||||
|
||||
# Build artifacts from backend
|
||||
surfsense_backend/podcasts/
|
||||
surfsense_backend/temp_audio/
|
||||
surfsense_backend/*.bak
|
||||
surfsense_backend/*.dat
|
||||
surfsense_backend/*.dir
|
||||
|
||||
# GitHub
|
||||
.github/
|
||||
|
||||
# Browser extension (not needed for main deployment)
|
||||
surfsense_browser_extension/
|
||||
|
||||
|
|
@ -26,6 +26,7 @@ permissions:
|
|||
jobs:
|
||||
tag_release:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch'
|
||||
outputs:
|
||||
new_tag: ${{ steps.tag_version.outputs.next_version }}
|
||||
steps:
|
||||
|
|
@ -86,6 +87,7 @@ jobs:
|
|||
|
||||
build:
|
||||
needs: tag_release
|
||||
if: always() && (needs.tag_release.result == 'success' || needs.tag_release.result == 'skipped')
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
packages: write
|
||||
|
|
@ -121,6 +123,12 @@ jobs:
|
|||
id: image
|
||||
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.image.outputs.name }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
|
@ -139,14 +147,15 @@ jobs:
|
|||
sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true
|
||||
docker system prune -af
|
||||
|
||||
- name: Build and push ${{ matrix.name }} (${{ matrix.suffix }})
|
||||
- name: Build and push by digest ${{ matrix.name }} (${{ matrix.suffix }})
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
push: true
|
||||
tags: ${{ steps.image.outputs.name }}:${{ needs.tag_release.outputs.new_tag }}-${{ matrix.suffix }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.image.outputs.name }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: ${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.suffix }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.suffix }}
|
||||
|
|
@ -159,9 +168,24 @@ jobs:
|
|||
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__' || '' }}
|
||||
${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image }}-${{ matrix.suffix }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
create_manifest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tag_release, build]
|
||||
if: always() && needs.build.result == 'success'
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
|
@ -170,7 +194,9 @@ jobs:
|
|||
matrix:
|
||||
include:
|
||||
- name: surfsense-backend
|
||||
image: backend
|
||||
- name: surfsense-web
|
||||
image: web
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
|
||||
|
||||
|
|
@ -179,6 +205,21 @@ jobs:
|
|||
id: image
|
||||
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download amd64 digest
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image }}-amd64
|
||||
path: /tmp/digests
|
||||
|
||||
- name: Download arm64 digest
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image }}-arm64
|
||||
path: /tmp/digests
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
|
@ -186,35 +227,41 @@ jobs:
|
|||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
- name: Compute app version
|
||||
id: appver
|
||||
run: |
|
||||
VERSION_TAG="${{ needs.tag_release.outputs.new_tag }}"
|
||||
IMAGE="${{ steps.image.outputs.name }}"
|
||||
APP_VERSION=$(echo "$VERSION_TAG" | rev | cut -d. -f2- | rev)
|
||||
|
||||
docker manifest create ${IMAGE}:${VERSION_TAG} \
|
||||
${IMAGE}:${VERSION_TAG}-amd64 \
|
||||
${IMAGE}:${VERSION_TAG}-arm64
|
||||
|
||||
docker manifest push ${IMAGE}:${VERSION_TAG}
|
||||
|
||||
if [[ "${{ github.ref }}" == "refs/heads/${{ github.event.repository.default_branch }}" ]] || [[ "${{ github.event.inputs.branch }}" == "${{ github.event.repository.default_branch }}" ]]; then
|
||||
docker manifest create ${IMAGE}:${APP_VERSION} \
|
||||
${IMAGE}:${VERSION_TAG}-amd64 \
|
||||
${IMAGE}:${VERSION_TAG}-arm64
|
||||
|
||||
docker manifest push ${IMAGE}:${APP_VERSION}
|
||||
|
||||
docker manifest create ${IMAGE}:latest \
|
||||
${IMAGE}:${VERSION_TAG}-amd64 \
|
||||
${IMAGE}:${VERSION_TAG}-arm64
|
||||
|
||||
docker manifest push ${IMAGE}:latest
|
||||
if [ -n "$VERSION_TAG" ]; then
|
||||
APP_VERSION=$(echo "$VERSION_TAG" | rev | cut -d. -f2- | rev)
|
||||
else
|
||||
APP_VERSION=""
|
||||
fi
|
||||
echo "app_version=$APP_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.tag_release.outputs.new_tag }},enable=${{ needs.tag_release.outputs.new_tag != '' }}
|
||||
type=raw,value=${{ steps.appver.outputs.app_version }},enable=${{ needs.tag_release.outputs.new_tag != '' && (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event.inputs.branch == github.event.repository.default_branch) }}
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=git-
|
||||
flavor: |
|
||||
latest=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event.inputs.branch == github.event.repository.default_branch }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ steps.image.outputs.name }}@sha256:%s ' *)
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ steps.image.outputs.name }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
run: |
|
||||
echo "Multi-arch manifest created for ${{ matrix.name }}!"
|
||||
echo "Versioned: ${{ steps.image.outputs.name }}:${{ needs.tag_release.outputs.new_tag }}"
|
||||
echo "App version: ${{ steps.image.outputs.name }}:$(echo '${{ needs.tag_release.outputs.new_tag }}' | rev | cut -d. -f2- | rev)"
|
||||
echo "Latest: ${{ steps.image.outputs.name }}:latest"
|
||||
echo "Tags: $(jq -cr '.tags | join(", ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")"
|
||||
|
|
@ -33,9 +33,9 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
|||
# Ports (change to avoid conflicts with other services on your machine)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# BACKEND_PORT=8000
|
||||
# FRONTEND_PORT=3000
|
||||
# ELECTRIC_PORT=5133
|
||||
# BACKEND_PORT=8929
|
||||
# FRONTEND_PORT=3929
|
||||
# ELECTRIC_PORT=5929
|
||||
# FLOWER_PORT=5555
|
||||
|
||||
# ==============================================================================
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
# For production with prebuilt images, use docker/docker-compose.yml instead.
|
||||
# =============================================================================
|
||||
|
||||
name: surfsense
|
||||
name: surfsense-dev
|
||||
|
||||
services:
|
||||
db:
|
||||
|
|
@ -162,8 +162,9 @@ services:
|
|||
image: electricsql/electric:1.4.10
|
||||
ports:
|
||||
- "${ELECTRIC_PORT:-5133}:3000"
|
||||
# depends_on:
|
||||
# - db
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
- ELECTRIC_INSECURE=true
|
||||
|
|
@ -197,10 +198,10 @@ services:
|
|||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: surfsense-postgres
|
||||
name: surfsense-dev-postgres
|
||||
pgadmin_data:
|
||||
name: surfsense-pgadmin
|
||||
name: surfsense-dev-pgadmin
|
||||
redis_data:
|
||||
name: surfsense-redis
|
||||
name: surfsense-dev-redis
|
||||
shared_temp:
|
||||
name: surfsense-shared-temp
|
||||
name: surfsense-dev-shared-temp
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ services:
|
|||
backend:
|
||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
- "${BACKEND_PORT:-8929}:8000"
|
||||
volumes:
|
||||
- shared_temp:/shared_tmp
|
||||
env_file:
|
||||
|
|
@ -61,7 +61,7 @@ services:
|
|||
UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
|
||||
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
|
||||
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
|
||||
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3000}}
|
||||
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
|
||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||
# DAYTONA_SANDBOX_ENABLED: "TRUE"
|
||||
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
|
||||
|
|
@ -151,7 +151,7 @@ services:
|
|||
electric:
|
||||
image: electricsql/electric:1.4.10
|
||||
ports:
|
||||
- "${ELECTRIC_PORT:-5133}:3000"
|
||||
- "${ELECTRIC_PORT:-5929}:3000"
|
||||
environment:
|
||||
DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
ELECTRIC_INSECURE: "true"
|
||||
|
|
@ -169,10 +169,10 @@ services:
|
|||
frontend:
|
||||
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
- "${FRONTEND_PORT:-3929}:3000"
|
||||
environment:
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8000}}
|
||||
NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:${ELECTRIC_PORT:-5133}}
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}}
|
||||
NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:${ELECTRIC_PORT:-5929}}
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
|
||||
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
|
||||
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
|
||||
|
|
|
|||
|
|
@ -321,9 +321,9 @@ Write-Host " OSS Alternative to NotebookLM for Teams [$versionDisplay]"
|
|||
Write-Host ("=" * 62) -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Write-Info " Frontend: http://localhost:3000"
|
||||
Write-Info " Backend: http://localhost:8000"
|
||||
Write-Info " API Docs: http://localhost:8000/docs"
|
||||
Write-Info " Frontend: http://localhost:3929"
|
||||
Write-Info " Backend: http://localhost:8929"
|
||||
Write-Info " API Docs: http://localhost:8929/docs"
|
||||
Write-Info ""
|
||||
Write-Info " Config: $InstallDir\.env"
|
||||
Write-Info " Logs: cd $InstallDir; docker compose logs -f"
|
||||
|
|
|
|||
|
|
@ -304,9 +304,9 @@ _version_display="${_version_display:-latest}"
|
|||
printf " OSS Alternative to NotebookLM for Teams ${YELLOW}[%s]${NC}\n" "${_version_display}"
|
||||
printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n\n"
|
||||
|
||||
info " Frontend: http://localhost:3000"
|
||||
info " Backend: http://localhost:8000"
|
||||
info " API Docs: http://localhost:8000/docs"
|
||||
info " Frontend: http://localhost:3929"
|
||||
info " Backend: http://localhost:8929"
|
||||
info " API Docs: http://localhost:8929/docs"
|
||||
info ""
|
||||
info " Config: ${INSTALL_DIR}/.env"
|
||||
info " Logs: cd ${INSTALL_DIR} && ${DC} logs -f"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ import { useEffect } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -108,37 +115,26 @@ export default function MorePagesPage() {
|
|||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||
task.completed
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{task.completed ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Star className="h-4 w-4" />
|
||||
)}
|
||||
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
task.completed &&
|
||||
"text-muted-foreground line-through"
|
||||
task.completed && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+{task.pages_reward} pages
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={task.completed ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
disabled={
|
||||
task.completed || completeMutation.isPending
|
||||
}
|
||||
disabled={task.completed || completeMutation.isPending}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
asChild={!task.completed}
|
||||
>
|
||||
|
|
@ -181,8 +177,9 @@ export default function MorePagesPage() {
|
|||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
For a limited time, get <span className="font-semibold text-foreground">6,000 additional pages</span> at
|
||||
no cost. Contact us and we'll upgrade your account instantly.
|
||||
For a limited time, get{" "}
|
||||
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
|
||||
cost. Contact us and we'll upgrade your account instantly.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-2">
|
||||
|
|
@ -196,9 +193,7 @@ export default function MorePagesPage() {
|
|||
<DialogContent className="select-none sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Get in Touch</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the option that works best for you.
|
||||
</DialogDescription>
|
||||
<DialogDescription>Pick the option that works best for you.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button asChild>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
|||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
|
|
@ -405,7 +405,6 @@ export default function NewChatPage() {
|
|||
id: currentThread?.id ?? null,
|
||||
visibility: currentThread?.visibility ?? null,
|
||||
hasComments: currentThread?.has_comments ?? false,
|
||||
addingCommentToMessageId: null,
|
||||
}));
|
||||
}, [currentThread, setCurrentThreadState]);
|
||||
|
||||
|
|
@ -1669,7 +1668,7 @@ export default function NewChatPage() {
|
|||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread messageThinkingSteps={messageThinkingSteps} />
|
||||
</div>
|
||||
<ReportPanel />
|
||||
<MobileReportPanel />
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
import Image from "next/image";
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: "SurfSense Docs",
|
||||
title: (
|
||||
<>
|
||||
<Image src="/icon-128.svg" alt="SurfSense" width={24} height={24} className="dark:invert" />
|
||||
SurfSense Docs
|
||||
</>
|
||||
),
|
||||
},
|
||||
githubUrl: "https://github.com/MODSetter/SurfSense",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -55,7 +55,37 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/docker-installation",
|
||||
url: "https://www.surfsense.com/docs/prerequisites",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/docker-installation/install-script",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/docker-installation/docker-compose",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/docker-installation/updating",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/docker-installation/dev-compose",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/docker-installation/migrate-from-allinone",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
|
|
@ -163,6 +193,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/obsidian",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/slack",
|
||||
lastModified,
|
||||
|
|
@ -182,5 +218,24 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/how-to/realtime-collaboration",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
// Developer documentation
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/testing",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/code-of-conduct",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.7,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,17 @@
|
|||
import { atom } from "jotai";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
import { reportPanelAtom, reportPanelOpenAtom } from "./report-panel.atom";
|
||||
|
||||
// TODO: Update `hasComments` to true when the first comment is created on a thread.
|
||||
// Currently it only updates on thread load. The gutter still works because
|
||||
// `addingCommentToMessageId` keeps it open, but the state is technically stale.
|
||||
|
||||
// TODO: Reset `addingCommentToMessageId` to null after a comment is successfully created.
|
||||
// Currently it stays set until navigation or clicking another message's bubble.
|
||||
// Not causing issues since panel visibility is driven by per-message comment count.
|
||||
|
||||
// TODO: Consider calling `resetCurrentThreadAtom` when unmounting the chat page
|
||||
// for explicit cleanup, though React navigation handles this implicitly.
|
||||
import { reportPanelAtom } from "./report-panel.atom";
|
||||
|
||||
interface CurrentThreadState {
|
||||
id: number | null;
|
||||
visibility: ChatVisibility | null;
|
||||
hasComments: boolean;
|
||||
addingCommentToMessageId: number | null;
|
||||
/** Whether the right-side comments panel is collapsed (desktop only) */
|
||||
commentsCollapsed: boolean;
|
||||
}
|
||||
|
||||
const initialState: CurrentThreadState = {
|
||||
id: null,
|
||||
visibility: null,
|
||||
hasComments: false,
|
||||
addingCommentToMessageId: null,
|
||||
commentsCollapsed: false,
|
||||
};
|
||||
|
||||
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
|
||||
|
|
@ -36,63 +20,22 @@ export const commentsEnabledAtom = atom(
|
|||
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
export const showCommentsGutterAtom = atom((get) => {
|
||||
const thread = get(currentThreadAtom);
|
||||
// Hide gutter if comments are collapsed
|
||||
if (thread.commentsCollapsed) return false;
|
||||
// Hide gutter if report panel is open (report panel takes the right side)
|
||||
if (get(reportPanelOpenAtom)) return false;
|
||||
return (
|
||||
thread.visibility === "SEARCH_SPACE" &&
|
||||
(thread.hasComments || thread.addingCommentToMessageId !== null)
|
||||
);
|
||||
});
|
||||
|
||||
export const addingCommentToMessageIdAtom = atom(
|
||||
(get) => get(currentThreadAtom).addingCommentToMessageId,
|
||||
(get, set, messageId: number | null) => {
|
||||
set(currentThreadAtom, { ...get(currentThreadAtom), addingCommentToMessageId: messageId });
|
||||
}
|
||||
);
|
||||
|
||||
// Setter atom for updating thread visibility
|
||||
export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => {
|
||||
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
|
||||
});
|
||||
|
||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||
set(currentThreadAtom, initialState);
|
||||
// Also close the report panel when resetting the thread
|
||||
set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null });
|
||||
});
|
||||
|
||||
/** Atom to read whether comments panel is collapsed */
|
||||
export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
|
||||
|
||||
/** Atom to toggle the comments collapsed state */
|
||||
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
|
||||
const current = get(currentThreadAtom);
|
||||
set(currentThreadAtom, { ...current, commentsCollapsed: !current.commentsCollapsed });
|
||||
});
|
||||
|
||||
/** Atom to explicitly set the comments collapsed state */
|
||||
export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => {
|
||||
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed });
|
||||
});
|
||||
|
||||
/** Target comment ID to scroll to (from URL navigation or inbox click) */
|
||||
export const targetCommentIdAtom = atom<number | null>(null);
|
||||
|
||||
/** Setter for target comment ID - also ensures comments are not collapsed */
|
||||
export const setTargetCommentIdAtom = atom(null, (get, set, commentId: number | null) => {
|
||||
// Ensure comments are not collapsed when navigating to a comment
|
||||
if (commentId !== null) {
|
||||
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: false });
|
||||
}
|
||||
export const setTargetCommentIdAtom = atom(null, (_, set, commentId: number | null) => {
|
||||
set(targetCommentIdAtom, commentId);
|
||||
});
|
||||
|
||||
/** Clear target after navigation completes */
|
||||
export const clearTargetCommentIdAtom = atom(null, (_, set) => {
|
||||
set(targetCommentIdAtom, null);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||
|
||||
interface ReportPanelState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -43,10 +45,14 @@ export const openReportPanelAtom = atom(
|
|||
wordCount: wordCount ?? null,
|
||||
shareToken: shareToken ?? null,
|
||||
});
|
||||
set(rightPanelTabAtom, "report");
|
||||
set(rightPanelCollapsedAtom, false);
|
||||
set(documentsSidebarOpenAtom, true);
|
||||
}
|
||||
);
|
||||
|
||||
/** Action atom to close the report panel */
|
||||
export const closeReportPanelAtom = atom(null, (_, set) => {
|
||||
export const closeReportPanelAtom = atom(null, (_get, set) => {
|
||||
set(reportPanelAtom, initialState);
|
||||
set(rightPanelTabAtom, "sources");
|
||||
});
|
||||
|
|
|
|||
8
surfsense_web/atoms/layout/right-panel.atom.ts
Normal file
8
surfsense_web/atoms/layout/right-panel.atom.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export type RightPanelTab = "sources" | "report";
|
||||
|
||||
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
|
||||
|
||||
/** Whether the right panel is collapsed (hidden but state preserved) */
|
||||
export const rightPanelCollapsedAtom = atom(false);
|
||||
|
|
@ -6,16 +6,11 @@ import {
|
|||
useAssistantState,
|
||||
useMessage,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
addingCommentToMessageIdAtom,
|
||||
commentsCollapsedAtom,
|
||||
commentsEnabledAtom,
|
||||
targetCommentIdAtom,
|
||||
} from "@/atoms/chat/current-thread.atom";
|
||||
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import {
|
||||
|
|
@ -26,7 +21,6 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
|||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
||||
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -96,20 +90,17 @@ function parseMessageId(assistantUiMessageId: string | undefined): number | null
|
|||
}
|
||||
|
||||
export const AssistantMessage: FC = () => {
|
||||
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const [isInlineOpen, setIsInlineOpen] = useState(false);
|
||||
const messageRef = useRef<HTMLDivElement>(null);
|
||||
const commentPanelRef = useRef<HTMLDivElement>(null);
|
||||
const commentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const dbMessageId = parseMessageId(messageId);
|
||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||
const commentsCollapsed = useAtomValue(commentsCollapsedAtom);
|
||||
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
|
||||
addingCommentToMessageIdAtom
|
||||
);
|
||||
|
||||
// Screen size detection for responsive comment UI
|
||||
// Mobile: < 768px (bottom sheet), Medium: 768px - 1024px (right sheet), Desktop: >= 1024px (inline panel)
|
||||
// Desktop: >= 1024px (inline expandable), Medium: 768px-1023px (right sheet), Mobile: <768px (bottom sheet)
|
||||
const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
|
|
@ -122,10 +113,8 @@ export const AssistantMessage: FC = () => {
|
|||
enabled: !!dbMessageId,
|
||||
});
|
||||
|
||||
// Target comment navigation - read target from global atom
|
||||
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
||||
|
||||
// Check if target comment belongs to this message (including replies)
|
||||
const hasTargetComment = useMemo(() => {
|
||||
if (!targetCommentId || !commentsData?.comments) return false;
|
||||
return commentsData.comments.some(
|
||||
|
|
@ -135,27 +124,36 @@ export const AssistantMessage: FC = () => {
|
|||
|
||||
const commentCount = commentsData?.total_count ?? 0;
|
||||
const hasComments = commentCount > 0;
|
||||
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
||||
const showCommentPanel = hasComments || isAddingComment;
|
||||
|
||||
const handleToggleAddComment = () => {
|
||||
if (!dbMessageId) return;
|
||||
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
|
||||
};
|
||||
|
||||
const handleCommentTriggerClick = () => {
|
||||
setIsSheetOpen(true);
|
||||
};
|
||||
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||
|
||||
// Close floating panel when clicking outside (but not on portaled popover/dropdown content)
|
||||
useEffect(() => {
|
||||
if (!messageRef.current) return;
|
||||
const el = messageRef.current;
|
||||
const update = () => setMessageHeight(el.offsetHeight);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
if (!isInlineOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Element;
|
||||
if (
|
||||
commentPanelRef.current?.contains(target) ||
|
||||
commentTriggerRef.current?.contains(target) ||
|
||||
target.closest?.("[data-radix-popper-content-wrapper]")
|
||||
)
|
||||
return;
|
||||
setIsInlineOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isInlineOpen]);
|
||||
|
||||
// Auto-open floating panel on desktop when this message has the target comment
|
||||
useEffect(() => {
|
||||
if (hasTargetComment && isDesktop && commentsLoaded) {
|
||||
setIsInlineOpen(true);
|
||||
const timeoutId = setTimeout(() => {
|
||||
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
||||
|
||||
// Auto-open sheet on mobile/tablet when this message has the target comment
|
||||
useEffect(() => {
|
||||
|
|
@ -164,20 +162,6 @@ export const AssistantMessage: FC = () => {
|
|||
}
|
||||
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
||||
|
||||
// Scroll message into view when it contains target comment (desktop)
|
||||
useEffect(() => {
|
||||
if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) {
|
||||
// Small delay to ensure DOM is ready after comments render
|
||||
const timeoutId = setTimeout(() => {
|
||||
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
||||
|
||||
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||
|
||||
// Determine sheet side based on screen size
|
||||
const sheetSide = isMediumScreen ? "right" : "bottom";
|
||||
|
||||
return (
|
||||
|
|
@ -186,54 +170,25 @@ export const AssistantMessage: FC = () => {
|
|||
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
|
||||
{/* Desktop comment panel - only on lg screens and above, hidden when collapsed */}
|
||||
{searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
|
||||
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
|
||||
<div
|
||||
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
|
||||
>
|
||||
{!hasComments && (
|
||||
<CommentTrigger
|
||||
commentCount={0}
|
||||
isOpen={isAddingComment}
|
||||
onClick={handleToggleAddComment}
|
||||
disabled={!dbMessageId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCommentPanel && dbMessageId && (
|
||||
<div
|
||||
className={
|
||||
hasComments ? "" : "mt-2 animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
}
|
||||
>
|
||||
<CommentPanelContainer
|
||||
messageId={dbMessageId}
|
||||
isOpen={true}
|
||||
maxHeight={messageHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
|
||||
{showCommentTrigger && !isDesktop && (
|
||||
<div className="ml-2 mt-1 flex justify-start">
|
||||
{/* Comment trigger — right-aligned, just below user query on all screen sizes */}
|
||||
{showCommentTrigger && (
|
||||
<div className="mr-2 mb-1 flex justify-end">
|
||||
<button
|
||||
ref={isDesktop ? commentTriggerRef : undefined}
|
||||
type="button"
|
||||
onClick={handleCommentTriggerClick}
|
||||
onClick={
|
||||
isDesktop ? () => setIsInlineOpen((prev) => !prev) : () => setIsSheetOpen(true)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
|
||||
hasComments
|
||||
? "border border-primary/50 bg-primary/5 text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
|
||||
isDesktop && isInlineOpen
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasComments
|
||||
? "text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className={cn("size-4", hasComments && "fill-current")} />
|
||||
<MessageSquare className={cn("size-3.5", hasComments && "fill-current")} />
|
||||
{hasComments ? (
|
||||
<span>
|
||||
{commentCount} {commentCount === 1 ? "comment" : "comments"}
|
||||
|
|
@ -245,7 +200,19 @@ export const AssistantMessage: FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment sheet - bottom for mobile, right for medium screens */}
|
||||
{/* Desktop floating comment panel — overlays on top of chat content */}
|
||||
{showCommentTrigger && isDesktop && isInlineOpen && dbMessageId && (
|
||||
<div
|
||||
ref={commentPanelRef}
|
||||
className="absolute right-0 top-10 z-30 w-full max-w-md animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
>
|
||||
<CommentPanelContainer messageId={dbMessageId} isOpen={true} variant="inline" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistantMessageInner />
|
||||
|
||||
{/* Comment sheet — bottom for mobile, right for medium screens */}
|
||||
{showCommentTrigger && !isDesktop && (
|
||||
<CommentSheet
|
||||
messageId={dbMessageId}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Link from "next/link";
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { type FC, forwardRef, useImperativeHandle, useMemo } from "react";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
|
|
@ -19,7 +20,6 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||
|
|
@ -47,400 +47,407 @@ interface ConnectorIndicatorProps {
|
|||
|
||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
({ showTrigger = true }, ref) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchParams = useSearchParams();
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchParams = useSearchParams();
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
||||
// Check if document summary LLM is properly configured
|
||||
// - If ID is 0 (Auto mode), we need global configs to be available
|
||||
// - If ID is positive (user config) or negative (specific global config), it's configured
|
||||
// - If ID is null/undefined, it's not configured
|
||||
const docSummaryLlmId = preferences.document_summary_llm_id;
|
||||
const isAutoMode = docSummaryLlmId === 0;
|
||||
const hasGlobalConfigs = globalConfigs.length > 0;
|
||||
// Check if document summary LLM is properly configured
|
||||
// - If ID is 0 (Auto mode), we need global configs to be available
|
||||
// - If ID is positive (user config) or negative (specific global config), it's configured
|
||||
// - If ID is null/undefined, it's not configured
|
||||
const docSummaryLlmId = preferences.document_summary_llm_id;
|
||||
const isAutoMode = docSummaryLlmId === 0;
|
||||
const hasGlobalConfigs = globalConfigs.length > 0;
|
||||
|
||||
const hasDocumentSummaryLLM =
|
||||
docSummaryLlmId !== null &&
|
||||
docSummaryLlmId !== undefined &&
|
||||
// If it's Auto mode, we need global configs to actually be available
|
||||
(!isAutoMode || hasGlobalConfigs);
|
||||
const hasDocumentSummaryLLM =
|
||||
docSummaryLlmId !== null &&
|
||||
docSummaryLlmId !== undefined &&
|
||||
// If it's Auto mode, we need global configs to actually be available
|
||||
(!isAutoMode || hasGlobalConfigs);
|
||||
|
||||
const llmConfigLoading = preferencesLoading || globalConfigsLoading;
|
||||
const llmConfigLoading = preferencesLoading || globalConfigsLoading;
|
||||
|
||||
// Fetch document type counts via the lightweight /type-counts endpoint (cached 10 min)
|
||||
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
||||
useAtomValue(documentTypeCountsAtom);
|
||||
// Fetch document type counts via the lightweight /type-counts endpoint (cached 10 min)
|
||||
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
||||
useAtomValue(documentTypeCountsAtom);
|
||||
|
||||
// Read status inbox items from shared atom (populated by LayoutDataProvider)
|
||||
// instead of creating a duplicate useInbox("status") hook.
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
const inboxItems = useMemo(
|
||||
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||
[statusInboxItems]
|
||||
);
|
||||
// Read status inbox items from shared atom (populated by LayoutDataProvider)
|
||||
// instead of creating a duplicate useInbox("status") hook.
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
const inboxItems = useMemo(
|
||||
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||
[statusInboxItems]
|
||||
);
|
||||
|
||||
// Check if YouTube view is active
|
||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
||||
// Check if YouTube view is active
|
||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
||||
|
||||
// Use the custom hook for dialog state management
|
||||
const {
|
||||
isOpen,
|
||||
activeTab,
|
||||
connectingId,
|
||||
isScrolled,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableSummary,
|
||||
handleOpenChange,
|
||||
handleTabChange,
|
||||
handleScroll,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
handleViewAccountsList,
|
||||
handleBackFromAccountsList,
|
||||
handleBackFromMCPList,
|
||||
handleAddNewMCPFromList,
|
||||
handleQuickIndexConnector,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
} = useConnectorDialog();
|
||||
// Use the custom hook for dialog state management
|
||||
const {
|
||||
isOpen,
|
||||
activeTab,
|
||||
connectingId,
|
||||
isScrolled,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableSummary,
|
||||
handleOpenChange,
|
||||
handleTabChange,
|
||||
handleScroll,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
handleViewAccountsList,
|
||||
handleBackFromAccountsList,
|
||||
handleBackFromMCPList,
|
||||
handleAddNewMCPFromList,
|
||||
handleQuickIndexConnector,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
} = useConnectorDialog();
|
||||
|
||||
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
||||
// This provides instant updates when connectors change, without polling
|
||||
const {
|
||||
connectors: connectorsFromElectric = [],
|
||||
loading: connectorsLoading,
|
||||
error: connectorsError,
|
||||
refreshConnectors: refreshConnectorsElectric,
|
||||
} = useConnectorsElectric(searchSpaceId);
|
||||
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
||||
// This provides instant updates when connectors change, without polling
|
||||
const {
|
||||
connectors: connectorsFromElectric = [],
|
||||
loading: connectorsLoading,
|
||||
error: connectorsError,
|
||||
refreshConnectors: refreshConnectorsElectric,
|
||||
} = useConnectorsElectric(searchSpaceId);
|
||||
|
||||
// Fallback to API if Electric is not available or fails
|
||||
// Use Electric data if: 1) we have data, or 2) still loading without error
|
||||
// Use API data if: Electric failed (has error) or finished loading with no data
|
||||
const useElectricData =
|
||||
connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError);
|
||||
const connectors = useElectricData ? connectorsFromElectric : allConnectors || [];
|
||||
// Fallback to API if Electric is not available or fails
|
||||
// Use Electric data if: 1) we have data, or 2) still loading without error
|
||||
// Use API data if: Electric failed (has error) or finished loading with no data
|
||||
const useElectricData =
|
||||
connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError);
|
||||
const connectors = useElectricData ? connectorsFromElectric : allConnectors || [];
|
||||
|
||||
// Manual refresh function that works with both Electric and API
|
||||
const refreshConnectors = async () => {
|
||||
if (useElectricData) {
|
||||
await refreshConnectorsElectric();
|
||||
} else {
|
||||
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
|
||||
// The connectorsAtom will handle refetching if needed
|
||||
}
|
||||
};
|
||||
// Manual refresh function that works with both Electric and API
|
||||
const refreshConnectors = async () => {
|
||||
if (useElectricData) {
|
||||
await refreshConnectorsElectric();
|
||||
} else {
|
||||
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
|
||||
// The connectorsAtom will handle refetching if needed
|
||||
}
|
||||
};
|
||||
|
||||
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
|
||||
// Also clears when failed notifications are detected
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[],
|
||||
inboxItems
|
||||
);
|
||||
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
|
||||
// Also clears when failed notifications are detected
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[],
|
||||
inboxItems
|
||||
);
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
||||
// Get document types that have documents in the search space
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||
: [];
|
||||
// Get document types that have documents in the search space
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||
: [];
|
||||
|
||||
const hasConnectors = connectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = connectors.length + activeDocumentTypes.length;
|
||||
const hasConnectors = connectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = connectors.length + activeDocumentTypes.length;
|
||||
|
||||
const activeConnectorsCount = connectors.length;
|
||||
const activeConnectorsCount = connectors.length;
|
||||
|
||||
// Check which connectors are already connected
|
||||
// Using Electric SQL + PGlite for real-time connector updates
|
||||
const connectedTypes = new Set<string>(
|
||||
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||
);
|
||||
// Check which connectors are already connected
|
||||
// Using Electric SQL + PGlite for real-time connector updates
|
||||
const connectedTypes = new Set<string>(
|
||||
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => handleOpenChange(true),
|
||||
}));
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => handleOpenChange(true),
|
||||
}));
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
{showTrigger && (
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
|
||||
side="bottom"
|
||||
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 font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
||||
}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
{activeConnectorsCount > 0 && (
|
||||
<span className="absolute -top-0.5 right-0 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 select-none">
|
||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
) : viewingAccountsType ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={(connectors || []) as SearchSourceConnector[]} // Using Electric SQL + PGlite for real-time connector updates (all connector types)
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
) ||
|
||||
COMPOSIO_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
}}
|
||||
isConnecting={connectingId !== null}
|
||||
/>
|
||||
) : connectingConnectorType ? (
|
||||
<ConnectorConnectView
|
||||
connectorType={connectingConnectorType}
|
||||
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
|
||||
onBack={handleBackFromConnect}
|
||||
isSubmitting={isCreatingConnector}
|
||||
/>
|
||||
) : editingConnector ? (
|
||||
<ConnectorEditView
|
||||
connector={{
|
||||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
// Sync last_indexed_at with live data from Electric SQL for real-time updates
|
||||
last_indexed_at:
|
||||
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
|
||||
?.last_indexed_at ?? editingConnector.last_indexed_at,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableSummary={enableSummary}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||
searchSpaceId={searchSpaceId?.toString()}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableSummaryChange={setEnableSummary}
|
||||
onSave={() => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleSaveConnector(() => refreshConnectors());
|
||||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={
|
||||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||
? () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
{showTrigger && (
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
tooltip={
|
||||
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
|
||||
}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
) : indexingConfig ? (
|
||||
<IndexingConfigurationView
|
||||
config={indexingConfig}
|
||||
connector={
|
||||
indexingConnector
|
||||
? {
|
||||
...indexingConnector,
|
||||
config: indexingConnectorConfig || indexingConnector.config,
|
||||
}
|
||||
: undefined
|
||||
side="bottom"
|
||||
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 font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasConnectors
|
||||
? `View ${activeConnectorsCount} connectors`
|
||||
: "Add your first connector"
|
||||
}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableSummary={enableSummary}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableSummaryChange={setEnableSummary}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => {
|
||||
if (indexingConfig.connectorId) {
|
||||
startIndexing(indexingConfig.connectorId);
|
||||
}
|
||||
handleStartIndexing(() => refreshConnectors());
|
||||
}}
|
||||
onSkip={handleSkipIndexing}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{/* Header */}
|
||||
<ConnectorDialogHeader
|
||||
activeTab={activeTab}
|
||||
totalSourceCount={activeConnectorsCount}
|
||||
searchQuery={searchQuery}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
isScrolled={isScrolled}
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
{activeConnectorsCount > 0 && (
|
||||
<span className="absolute -top-0.5 right-0 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 select-none">
|
||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
) : viewingAccountsType ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={(connectors || []) as SearchSourceConnector[]} // Using Electric SQL + PGlite for real-time connector updates (all connector types)
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
) ||
|
||||
COMPOSIO_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
}}
|
||||
isConnecting={connectingId !== null}
|
||||
/>
|
||||
) : connectingConnectorType ? (
|
||||
<ConnectorConnectView
|
||||
connectorType={connectingConnectorType}
|
||||
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
|
||||
onBack={handleBackFromConnect}
|
||||
isSubmitting={isCreatingConnector}
|
||||
/>
|
||||
) : editingConnector ? (
|
||||
<ConnectorEditView
|
||||
connector={{
|
||||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
// Sync last_indexed_at with live data from Electric SQL for real-time updates
|
||||
last_indexed_at:
|
||||
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
|
||||
?.last_indexed_at ?? editingConnector.last_indexed_at,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableSummary={enableSummary}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||
searchSpaceId={searchSpaceId?.toString()}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableSummaryChange={setEnableSummary}
|
||||
onSave={() => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleSaveConnector(() => refreshConnectors());
|
||||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={
|
||||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||
? () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
) : indexingConfig ? (
|
||||
<IndexingConfigurationView
|
||||
config={indexingConfig}
|
||||
connector={
|
||||
indexingConnector
|
||||
? {
|
||||
...indexingConnector,
|
||||
config: indexingConnectorConfig || indexingConnector.config,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableSummary={enableSummary}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableSummaryChange={setEnableSummary}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => {
|
||||
if (indexingConfig.connectorId) {
|
||||
startIndexing(indexingConfig.connectorId);
|
||||
}
|
||||
handleStartIndexing(() => refreshConnectors());
|
||||
}}
|
||||
onSkip={handleSkipIndexing}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{/* Header */}
|
||||
<ConnectorDialogHeader
|
||||
activeTab={activeTab}
|
||||
totalSourceCount={activeConnectorsCount}
|
||||
searchQuery={searchQuery}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
isScrolled={isScrolled}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
|
||||
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
|
||||
{/* LLM Configuration Warning */}
|
||||
{!llmConfigLoading && !hasDocumentSummaryLLM && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-3">
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
|
||||
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Go to Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
|
||||
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
|
||||
{/* LLM Configuration Warning */}
|
||||
{!llmConfigLoading && !hasDocumentSummaryLLM && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-3">
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
|
||||
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Go to Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TabsContent value="all" className="m-0">
|
||||
<AllConnectorsTab
|
||||
<TabsContent value="all" className="m-0">
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
searchSpaceId={searchSpaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={connectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onConnectOAuth={hasDocumentSummaryLLM ? handleConnectOAuth : () => {}}
|
||||
onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}}
|
||||
onCreateWebcrawler={
|
||||
hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}
|
||||
}
|
||||
onCreateYouTubeCrawler={
|
||||
hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {}
|
||||
}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<ActiveConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
searchSpaceId={searchSpaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={connectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
hasSources={hasSources}
|
||||
totalSourceCount={totalSourceCount}
|
||||
activeDocumentTypes={activeDocumentTypes}
|
||||
connectors={connectors as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onConnectOAuth={hasDocumentSummaryLLM ? handleConnectOAuth : () => {}}
|
||||
onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}}
|
||||
onCreateWebcrawler={hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}}
|
||||
onCreateYouTubeCrawler={
|
||||
hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {}
|
||||
}
|
||||
onTabChange={handleTabChange}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<ActiveConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
hasSources={hasSources}
|
||||
totalSourceCount={totalSourceCount}
|
||||
activeDocumentTypes={activeDocumentTypes}
|
||||
connectors={connectors as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onTabChange={handleTabChange}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom fade shadow */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
{/* Bottom fade shadow */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ConnectorIndicator.displayName = "ConnectorIndicator";
|
||||
|
|
|
|||
|
|
@ -70,23 +70,21 @@ export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSu
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
|
||||
up at{" "}
|
||||
<a
|
||||
href="https://qianfan.cloud.baidu.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
qianfan.cloud.baidu.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
|
||||
up at{" "}
|
||||
<a
|
||||
href="https://qianfan.cloud.baidu.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
qianfan.cloud.baidu.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -96,15 +96,13 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a BookStack API Token to use this connector. You can create one from your
|
||||
BookStack instance settings.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a BookStack API Token to use this connector. You can create one from your
|
||||
BookStack instance settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -64,15 +64,13 @@ export const CirclebackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmit
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Webhook className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
Circleback uses webhooks to automatically send meeting data. After connecting, you'll
|
||||
receive a webhook URL to configure in your Circleback settings.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Webhook className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Circleback uses webhooks to automatically send meeting data. After connecting, you'll
|
||||
receive a webhook URL to configure in your Circleback settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -172,14 +172,12 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -105,24 +105,21 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
A GitHub PAT is only required for private repositories. Public repos work without a
|
||||
token.{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
|
||||
>
|
||||
Get your token
|
||||
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</a>{" "}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
A GitHub PAT is only required for private repositories. Public repos work without a token.{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
|
||||
>
|
||||
Get your token
|
||||
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</a>{" "}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -70,22 +70,20 @@ export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.so
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.so
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -88,22 +88,20 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Luma API Key to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a Luma API Key to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 [&>svg]:top-2 sm:[&>svg]:top-3">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Server className="h-4 w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
|
||||
|
|
@ -230,55 +230,51 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<AlertTitle className="text-sm">
|
||||
{testResult.status === "success"
|
||||
? "Connection Successful"
|
||||
: "Connection Failed"}
|
||||
</AlertTitle>
|
||||
{testResult.tools.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 self-start sm:self-auto text-xs"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(!showDetails);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Hide Details</span>
|
||||
<span className="sm:hidden">Hide</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Show Details</span>
|
||||
<span className="sm:hidden">Show</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<AlertDescription className="text-[10px] sm:text-xs mt-1">
|
||||
{testResult.message}
|
||||
{showDetails && testResult.tools.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||
<p className="font-semibold mb-2">Available tools:</p>
|
||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool, i) => (
|
||||
<li key={i}>{tool.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
<div className="col-start-2 flex items-center justify-between">
|
||||
<AlertTitle className="text-sm">
|
||||
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
|
||||
</AlertTitle>
|
||||
{testResult.tools.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 self-start sm:self-auto text-xs"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(!showDetails);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Hide Details</span>
|
||||
<span className="sm:hidden">Hide</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Show Details</span>
|
||||
<span className="sm:hidden">Show</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<AlertDescription className="text-[10px] sm:text-xs mt-1">
|
||||
{testResult.message}
|
||||
{showDetails && testResult.tools.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||
<p className="font-semibold mb-2">Available tools:</p>
|
||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool, i) => (
|
||||
<li key={i}>{tool.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -102,15 +102,13 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
|
||||
This connector requires direct file system access and only works with self-hosted
|
||||
SurfSense installations.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0 text-purple-500" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
This connector requires direct file system access and only works with self-hosted
|
||||
SurfSense installations.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -123,23 +123,21 @@ export const SearxngConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You need access to a running SearxNG instance. Refer to the{" "}
|
||||
<a
|
||||
href="https://docs.searxng.org/admin/installation-docker.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
SearxNG installation guide
|
||||
</a>{" "}
|
||||
for setup instructions. If your instance requires an API key, include it below.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You need access to a running SearxNG instance. Refer to the{" "}
|
||||
<a
|
||||
href="https://docs.searxng.org/admin/installation-docker.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
SearxNG installation guide
|
||||
</a>{" "}
|
||||
for setup instructions. If your instance requires an API key, include it below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -70,22 +70,20 @@ export const TavilyApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0 mt-1">
|
||||
<AlertDescription className="text-[10px] sm:text-xs mt-1">
|
||||
Configure this URL in Circleback Settings → Automations → Create automation → Send
|
||||
webhook request. The webhook will automatically send meeting notes, transcripts, and
|
||||
action items to this search space.
|
||||
|
|
|
|||
|
|
@ -235,55 +235,51 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
|
||||
<AlertTitle className="text-sm">
|
||||
{testResult.status === "success"
|
||||
? "Connection Successful"
|
||||
: "Connection Failed"}
|
||||
</AlertTitle>
|
||||
{testResult.tools.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 self-start sm:self-auto text-xs"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(!showDetails);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Hide Details</span>
|
||||
<span className="sm:hidden">Hide</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Show Details</span>
|
||||
<span className="sm:hidden">Show</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<AlertDescription className="text-xs mt-1">
|
||||
{testResult.message}
|
||||
{showDetails && testResult.tools.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||
<p className="font-semibold mb-2">Available tools:</p>
|
||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool) => (
|
||||
<li key={tool.name}>{tool.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
<div className="col-start-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
|
||||
<AlertTitle className="text-sm">
|
||||
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
|
||||
</AlertTitle>
|
||||
{testResult.tools.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 self-start sm:self-auto text-xs"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(!showDetails);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Hide Details</span>
|
||||
<span className="sm:hidden">Hide</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Show Details</span>
|
||||
<span className="sm:hidden">Show</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<AlertDescription className="text-xs mt-1">
|
||||
{testResult.message}
|
||||
{showDetails && testResult.tools.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||
<p className="font-semibold mb-2">Available tools:</p>
|
||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool) => (
|
||||
<li key={tool.name}>{tool.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
|||
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-3 text-xs sm:text-sm">
|
||||
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<p className="text-muted-foreground">
|
||||
Want a quick answer from a webpage without indexing it? Just paste the URL directly into the chat instead.
|
||||
Want a quick answer from a webpage without indexing it? Just paste the URL directly into
|
||||
the chat instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -123,9 +124,9 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
|||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center gap-2 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Configuration is saved when you start indexing. You can update these settings anytime from
|
||||
the connector management page.
|
||||
</AlertDescription>
|
||||
|
|
|
|||
|
|
@ -280,9 +280,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
|
||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm">
|
||||
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("chat_tip")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("chat_tip")}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
AlertCircle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Cable,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
|
|
@ -23,17 +22,20 @@ import {
|
|||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
SquareLibrary,
|
||||
Unplug,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
mentionedDocumentsAtom,
|
||||
sidebarSelectedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
|
|
@ -48,6 +50,7 @@ import {
|
|||
ConnectorIndicator,
|
||||
type ConnectorIndicatorHandle,
|
||||
} from "@/components/assistant-ui/connector-popup";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
|
|
@ -60,13 +63,21 @@ import {
|
|||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "@/components/new-chat/document-mention-picker";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
|
|
@ -95,8 +106,6 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) =>
|
|||
};
|
||||
|
||||
const ThreadContent: FC = () => {
|
||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
||||
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
||||
|
|
@ -106,10 +115,7 @@ const ThreadContent: FC = () => {
|
|||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||
showGutter && "lg:pr-30"
|
||||
)}
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||
>
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
|
|
@ -228,6 +234,72 @@ const ThreadWelcome: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const BANNER_CONNECTORS = [
|
||||
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
||||
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
||||
{ type: "NOTION_CONNECTOR", label: "Notion" },
|
||||
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
|
||||
{ type: "SLACK_CONNECTOR", label: "Slack" },
|
||||
] as const;
|
||||
|
||||
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
|
||||
|
||||
const ConnectToolsBanner: FC = () => {
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
|
||||
});
|
||||
|
||||
const hasConnectors = (connectors?.length ?? 0) > 0;
|
||||
|
||||
if (dismissed || hasConnectors) return null;
|
||||
|
||||
const handleDismiss = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:hidden border-t border-border/50 bg-muted-foreground/[0.04]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left transition-colors hover:bg-muted-foreground/[0.06] active:bg-muted-foreground/[0.1]"
|
||||
onClick={() => setConnectorDialogOpen(true)}
|
||||
>
|
||||
<Unplug className="size-4 text-muted-foreground/70 shrink-0" />
|
||||
<span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span>
|
||||
<AvatarGroup className="shrink-0">
|
||||
{BANNER_CONNECTORS.map(({ type, label }, i) => (
|
||||
<Avatar key={type} className="size-6" style={{ zIndex: BANNER_CONNECTORS.length - i }}>
|
||||
<AvatarFallback className="bg-muted text-[10px]">
|
||||
{getConnectorIcon(type, "size-3.5")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDismiss}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleDismiss(e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
className="shrink-0 ml-0.5 p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Composer: FC = () => {
|
||||
// Document mention state (atoms persist across component remounts)
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
|
|
@ -312,6 +384,16 @@ const Composer: FC = () => {
|
|||
}
|
||||
}, [isThreadEmpty]);
|
||||
|
||||
// Close document picker when a slide-out panel (inbox, shared/private chats) opens
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
};
|
||||
window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
||||
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
// Sync editor text with assistant-ui composer runtime
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string) => {
|
||||
|
|
@ -425,9 +507,9 @@ const Composer: FC = () => {
|
|||
currentUserId={currentUser?.id ?? null}
|
||||
members={members ?? []}
|
||||
/>
|
||||
<div className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow">
|
||||
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
||||
{/* Inline editor with @mention support */}
|
||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
placeholder={currentPlaceholder}
|
||||
|
|
@ -466,6 +548,7 @@ const Composer: FC = () => {
|
|||
document.body
|
||||
)}
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
<ConnectToolsBanner />
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
|
|
@ -481,7 +564,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||
const connectorRef = useRef<ConnectorIndicatorHandle>(null);
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const connectorCount = connectors?.length ?? 0;
|
||||
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
|
|
@ -506,55 +591,61 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="aui-composer-action-wrapper relative mx-3 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover open={addMenuOpen} onOpenChange={setAddMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<DropdownMenu open={addMenuOpen} onOpenChange={setAddMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Configuration"
|
||||
tooltip="Add files and more"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Configuration"
|
||||
aria-label="Add files and more"
|
||||
data-joyride="connector-icon"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
className="w-[calc(100vw-2rem)] max-w-60 sm:w-60 p-2"
|
||||
className="w-[calc(100vw-2rem)] max-w-60 sm:w-60"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
setDocumentsSidebarOpen(true);
|
||||
}}
|
||||
>
|
||||
<SquareLibrary className="size-4 shrink-0" />
|
||||
Documents
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
connectorRef.current?.open();
|
||||
}}
|
||||
>
|
||||
<Cable className="size-4 shrink-0" />
|
||||
Manage connectors
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
openUploadDialog();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4 shrink-0" />
|
||||
Upload files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setAddMenuOpen(false);
|
||||
connectorRef.current?.open();
|
||||
}}
|
||||
>
|
||||
<Unplug className="size-4 shrink-0" />
|
||||
{connectorCount > 0 ? "Manage tools" : "Connect your tools"}
|
||||
{connectorCount > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{connectorCount}</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ConnectorIndicator ref={connectorRef} showTrigger={false} />
|
||||
{sidebarDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocumentsSidebarOpen(true)}
|
||||
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
||||
>
|
||||
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasModelConfigured && (
|
||||
|
|
@ -565,16 +656,6 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{sidebarDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocumentsSidebarOpen(true)}
|
||||
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
||||
>
|
||||
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Send, X } from "lucide-react";
|
||||
import { ArrowUp, Send, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
|
|
@ -86,6 +86,7 @@ export function CommentComposer({
|
|||
onCancel,
|
||||
autoFocus = false,
|
||||
initialValue = "",
|
||||
compact = false,
|
||||
}: CommentComposerProps) {
|
||||
const [displayContent, setDisplayContent] = useState(initialValue);
|
||||
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
||||
|
|
@ -257,44 +258,46 @@ export function CommentComposer({
|
|||
}, [adjustTextareaHeight]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover
|
||||
open={mentionState.isActive}
|
||||
onOpenChange={(open) => !open && closeMentionPicker()}
|
||||
modal={false}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={displayContent}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[40px] max-h-[200px] resize-none overflow-y-auto scrollbar-thin"
|
||||
rows={1}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="w-72 p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
<div className={cn("flex", compact ? "flex-row items-center gap-2" : "flex-col gap-2")}>
|
||||
<div className={cn(compact && "flex-1 min-w-0")}>
|
||||
<Popover
|
||||
open={mentionState.isActive}
|
||||
onOpenChange={(open) => !open && closeMentionPicker()}
|
||||
modal={false}
|
||||
>
|
||||
<MemberMentionPicker
|
||||
members={members}
|
||||
query={mentionState.query}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isLoading={membersLoading}
|
||||
onSelect={insertMention}
|
||||
onHighlightChange={setHighlightedIndex}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<PopoverAnchor asChild>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={displayContent}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[40px] max-h-[200px] w-full resize-none overflow-y-auto scrollbar-thin border-none shadow-none focus-visible:ring-0 bg-transparent dark:bg-transparent"
|
||||
rows={1}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="w-72 p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<MemberMentionPicker
|
||||
members={members}
|
||||
query={mentionState.query}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isLoading={membersLoading}
|
||||
onSelect={insertMention}
|
||||
onHighlightChange={setHighlightedIndex}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className={cn("flex items-center gap-2", !compact && "justify-end")}>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -309,13 +312,19 @@ export function CommentComposer({
|
|||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size={compact ? "icon" : "sm"}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(!canSubmit && "opacity-50")}
|
||||
className={cn(!canSubmit && "opacity-50", compact && "size-8 shrink-0 rounded-full")}
|
||||
>
|
||||
<Send className="mr-1 size-4" />
|
||||
{submitLabel}
|
||||
{compact ? (
|
||||
<ArrowUp className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-1 size-4" />
|
||||
{submitLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface CommentComposerProps {
|
|||
onCancel?: () => void;
|
||||
autoFocus?: boolean;
|
||||
initialValue?: string;
|
||||
/** Compact mode: inline send button with ArrowUp icon, no label */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export interface MentionState {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CommentActionsProps } from "./types";
|
||||
|
|
@ -34,7 +33,6 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
|||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canEdit && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ export interface CommentPanelContainerProps {
|
|||
messageId: number;
|
||||
isOpen: boolean;
|
||||
maxHeight?: number;
|
||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
|
||||
variant?: "desktop" | "mobile";
|
||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain, inline fits within message width */
|
||||
variant?: "desktop" | "mobile" | "inline";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentThread } from "../comment-thread/comment-thread";
|
||||
import type { CommentPanelProps } from "./types";
|
||||
|
||||
function getInitials(name: string | null | undefined, email: string): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
|
||||
export function CommentPanel({
|
||||
threads,
|
||||
members,
|
||||
|
|
@ -33,20 +18,23 @@ export function CommentPanel({
|
|||
maxHeight,
|
||||
variant = "desktop",
|
||||
}: CommentPanelProps) {
|
||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||
|
||||
const handleCommentSubmit = (content: string) => {
|
||||
onCreateComment(content);
|
||||
};
|
||||
|
||||
const isMobile = variant === "mobile";
|
||||
const isInline = variant === "inline";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[120px] items-center justify-center p-4",
|
||||
!isMobile && "w-96 rounded-lg border bg-card"
|
||||
isInline &&
|
||||
"w-full rounded-xl border-sidebar-border border bg-sidebar text-sidebar-foreground shadow-lg",
|
||||
!isMobile &&
|
||||
!isInline &&
|
||||
"w-96 rounded-lg border-sidebar-border border bg-sidebar text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
|
|
@ -65,8 +53,18 @@ export function CommentPanel({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col", isMobile ? "w-full" : "w-85 rounded-lg border bg-card")}
|
||||
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isMobile && "w-full",
|
||||
isInline &&
|
||||
"w-full rounded-xl border-sidebar-border border bg-sidebar text-sidebar-foreground shadow-lg max-h-80",
|
||||
!isMobile &&
|
||||
!isInline &&
|
||||
"w-85 rounded-lg border-sidebar-border border bg-sidebar text-sidebar-foreground"
|
||||
)}
|
||||
style={
|
||||
!isMobile && !isInline && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined
|
||||
}
|
||||
>
|
||||
{hasThreads && (
|
||||
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
|
||||
|
|
@ -87,25 +85,6 @@ export function CommentPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!hasThreads && currentUser && (
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-1">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage
|
||||
src={currentUser.avatar_url ?? undefined}
|
||||
alt={currentUser.display_name ?? currentUser.email}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
|
||||
{getInitials(currentUser.display_name, currentUser.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{currentUser.display_name ?? currentUser.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
|
||||
<CommentComposer
|
||||
members={members}
|
||||
|
|
@ -115,6 +94,7 @@ export function CommentPanel({
|
|||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleCommentSubmit}
|
||||
autoFocus={!hasThreads}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@ export interface CommentPanelProps {
|
|||
onDeleteComment: (commentId: number) => void;
|
||||
isSubmitting?: boolean;
|
||||
maxHeight?: number;
|
||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
|
||||
variant?: "desktop" | "mobile";
|
||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain, inline fits within message width */
|
||||
variant?: "desktop" | "mobile" | "inline";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export function CommentThread({
|
|||
onSubmit={handleReplySubmit}
|
||||
onCancel={handleReplyCancel}
|
||||
autoFocus
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CommentTriggerProps } from "./types";
|
||||
|
||||
export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: CommentTriggerProps) {
|
||||
const hasComments = commentCount > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={hasComments ? "outline" : isOpen ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative size-10 rounded-full transition-all duration-200",
|
||||
hasComments
|
||||
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
: isOpen
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
!hasComments && !isOpen && "opacity-0 group-hover:opacity-100",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<MessageSquarePlus className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
||||
{hasComments && (
|
||||
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{commentCount > 9 ? "9+" : commentCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export interface CommentTriggerProps {
|
||||
commentCount: number;
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export function MemberMentionItem({
|
|||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
|
||||
isHighlighted ? "bg-accent" : "hover:bg-accent/50"
|
||||
isHighlighted ? "bg-primary/15 text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => onSelect(member)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { StarIcon } from "lucide-react";
|
||||
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
|
||||
import { motion, useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -45,6 +53,122 @@ function useIsInView<T extends HTMLElement = HTMLElement>(
|
|||
return { ref: localRef, isInView };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Particles (for star burst effect on completion)
|
||||
// ---------------------------------------------------------------------------
|
||||
type ParticlesContextType = { animate: boolean; isInView: boolean };
|
||||
const [ParticlesProvider, useParticles] =
|
||||
getStrictContext<ParticlesContextType>("ParticlesContext");
|
||||
|
||||
function Particles({
|
||||
ref,
|
||||
animate = true,
|
||||
inView = false,
|
||||
inViewMargin = "0px",
|
||||
inViewOnce = true,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: Omit<HTMLMotionProps<"div">, "children"> & {
|
||||
animate?: boolean;
|
||||
children: React.ReactNode;
|
||||
} & UseIsInViewOptions) {
|
||||
const { ref: localRef, isInView } = useIsInView(ref as React.Ref<HTMLDivElement>, {
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
});
|
||||
return (
|
||||
<ParticlesProvider value={{ animate, isInView }}>
|
||||
<motion.div ref={localRef} style={{ position: "relative", ...style }} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</ParticlesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticlesEffect({
|
||||
side = "top",
|
||||
align = "center",
|
||||
count = 6,
|
||||
radius = 30,
|
||||
spread = 360,
|
||||
duration = 0.8,
|
||||
holdDelay = 0.05,
|
||||
sideOffset = 0,
|
||||
alignOffset = 0,
|
||||
delay = 0,
|
||||
transition,
|
||||
style,
|
||||
...props
|
||||
}: Omit<HTMLMotionProps<"div">, "children"> & {
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
align?: "start" | "center" | "end";
|
||||
count?: number;
|
||||
radius?: number;
|
||||
spread?: number;
|
||||
duration?: number;
|
||||
holdDelay?: number;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
delay?: number;
|
||||
}) {
|
||||
const { animate, isInView } = useParticles();
|
||||
const isVertical = side === "top" || side === "bottom";
|
||||
const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%";
|
||||
|
||||
const top = isVertical
|
||||
? side === "top"
|
||||
? `calc(0% - ${sideOffset}px)`
|
||||
: `calc(100% + ${sideOffset}px)`
|
||||
: `calc(${alignPct} + ${alignOffset}px)`;
|
||||
const left = isVertical
|
||||
? `calc(${alignPct} + ${alignOffset}px)`
|
||||
: side === "left"
|
||||
? `calc(0% - ${sideOffset}px)`
|
||||
: `calc(100% + ${sideOffset}px)`;
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top,
|
||||
left,
|
||||
transform: "translate(-50%, -50%)",
|
||||
};
|
||||
const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{animate &&
|
||||
isInView &&
|
||||
[...Array(count)].map((_, i) => {
|
||||
const angle = i * angleStep;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const y = Math.sin(angle) * radius;
|
||||
return (
|
||||
<motion.div
|
||||
key={`particle-${angle}`}
|
||||
style={{ ...containerStyle, ...style }}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{
|
||||
x: `${x}px`,
|
||||
y: `${y}px`,
|
||||
scale: [0, 1, 0],
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
delay: delay + i * holdDelay,
|
||||
ease: "easeOut",
|
||||
...transition,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-digit scrolling wheel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -193,42 +317,18 @@ function AnimatedStarCount({
|
|||
value,
|
||||
itemSize = 22,
|
||||
isRolling = false,
|
||||
animated = true,
|
||||
className,
|
||||
onComplete,
|
||||
}: {
|
||||
value: number;
|
||||
itemSize?: number;
|
||||
isRolling?: boolean;
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const formatted = numberFormatter.format(value);
|
||||
const chars = formatted.split("");
|
||||
|
||||
if (!animated) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{chars.map((char, idx) => (
|
||||
<div
|
||||
key={`static-${idx}-${char}`}
|
||||
className={className}
|
||||
style={{
|
||||
height: itemSize,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: char >= "0" && char <= "9" ? undefined : "0.3em",
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let totalDigits = 0;
|
||||
for (const c of chars) {
|
||||
if (c >= "0" && c <= "9") totalDigits++;
|
||||
|
|
@ -307,13 +407,13 @@ function NavbarGitHubStars({
|
|||
href = "https://github.com/MODSetter/SurfSense",
|
||||
className,
|
||||
}: NavbarGitHubStarsProps) {
|
||||
const [hasMounted, setHasMounted] = React.useState(false);
|
||||
const [stars, setStars] = React.useState(0);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isCompleted, setIsCompleted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
const fillRaw = useMotionValue(0);
|
||||
const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 });
|
||||
const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`);
|
||||
|
||||
React.useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
|
@ -324,6 +424,7 @@ function NavbarGitHubStars({
|
|||
.then((data) => {
|
||||
if (data && typeof data.stargazers_count === "number") {
|
||||
setStars(data.stargazers_count);
|
||||
fillRaw.set(1);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -333,7 +434,7 @@ function NavbarGitHubStars({
|
|||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
return () => abortController.abort();
|
||||
}, [username, repo]);
|
||||
}, [username, repo, fillRaw]);
|
||||
|
||||
return (
|
||||
<a
|
||||
|
|
@ -341,20 +442,37 @@ function NavbarGitHubStars({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group inline-flex items-center rounded-full border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm backdrop-blur-sm transition-colors dark:border-neutral-800 dark:bg-neutral-950/80",
|
||||
"hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
||||
"group flex items-center gap-2 rounded-full px-3 py-1.5 transition-colors",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 shrink-0 text-neutral-600 transition-colors dark:text-neutral-300 group-hover:text-neutral-800 dark:group-hover:text-neutral-100" />
|
||||
<div className="ml-2 flex items-center text-neutral-500 transition-colors dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200">
|
||||
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
|
||||
<div className="flex items-center gap-1 rounded-md bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200 dark:group-hover:bg-neutral-700 px-2 py-0.5 transition-colors">
|
||||
<AnimatedStarCount
|
||||
value={isLoading ? 10000 : stars}
|
||||
itemSize={ITEM_SIZE}
|
||||
isRolling={hasMounted && isLoading}
|
||||
animated={hasMounted}
|
||||
isRolling={isLoading}
|
||||
className="text-sm font-semibold tabular-nums text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
|
||||
onComplete={() => setIsCompleted(true)}
|
||||
/>
|
||||
<Particles animate={isCompleted}>
|
||||
<div className="relative size-4">
|
||||
<StarIcon
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-4 fill-neutral-400 stroke-neutral-400 dark:fill-neutral-700 dark:stroke-neutral-700 group-hover:fill-neutral-600 group-hover:stroke-neutral-600 dark:group-hover:fill-neutral-300 dark:group-hover:stroke-neutral-300 transition-colors"
|
||||
/>
|
||||
<motion.div className="absolute inset-0" style={{ clipPath }}>
|
||||
<StarIcon
|
||||
aria-hidden="true"
|
||||
className="size-4 fill-neutral-300 stroke-neutral-300 dark:fill-neutral-400 dark:stroke-neutral-400 group-hover:fill-neutral-500 group-hover:stroke-neutral-500 dark:group-hover:fill-neutral-200 dark:group-hover:stroke-neutral-200 transition-colors"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
<ParticlesEffect
|
||||
delay={0.3}
|
||||
className="size-1 rounded-full bg-neutral-300 dark:bg-neutral-400"
|
||||
/>
|
||||
</Particles>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -176,10 +176,9 @@ function GetStartedButton() {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
const BackgroundGrids = () => {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-0 grid h-full w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
||||
<div className="pointer-events-none absolute inset-0 z-0 grid h-screen w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
||||
<div className="relative h-full w-full">
|
||||
<GridLineVertical className="left-0" />
|
||||
<GridLineVertical className="left-auto right-0" />
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { toast } from "sonner";
|
|||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
|
|
@ -38,6 +39,7 @@ import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
|||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
|
|
@ -74,6 +76,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Announcements
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
|
|
@ -117,10 +120,23 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
|
||||
// Inbox sidebar state
|
||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
||||
|
||||
// Documents sidebar state (shared atom so Composer can toggle it)
|
||||
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||
const [isDocumentsDocked, setIsDocumentsDocked] = useState(true);
|
||||
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
|
||||
// Open documents sidebar by default on desktop (docked mode)
|
||||
const documentsInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!documentsInitialized.current) {
|
||||
documentsInitialized.current = true;
|
||||
const isDesktop = typeof window !== "undefined" && window.innerWidth >= 768;
|
||||
if (isDesktop) {
|
||||
setIsDocumentsSidebarOpen(true);
|
||||
}
|
||||
}
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
|
||||
// Announcements sidebar state
|
||||
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
||||
|
|
@ -304,7 +320,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
title: "Documents",
|
||||
url: "#documents",
|
||||
icon: SquareLibrary,
|
||||
isActive: isDocumentsSidebarOpen,
|
||||
isActive: isMobile
|
||||
? isDocumentsSidebarOpen
|
||||
: isDocumentsSidebarOpen && !isRightPanelCollapsed,
|
||||
statusIndicator: documentsProcessingStatus,
|
||||
},
|
||||
{
|
||||
|
|
@ -316,8 +334,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
},
|
||||
],
|
||||
[
|
||||
isMobile,
|
||||
isInboxSidebarOpen,
|
||||
isDocumentsSidebarOpen,
|
||||
isRightPanelCollapsed,
|
||||
totalUnreadCount,
|
||||
isAnnouncementsSidebarOpen,
|
||||
announcementUnreadCount,
|
||||
|
|
@ -419,7 +439,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
if (!prev) {
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}
|
||||
return !prev;
|
||||
|
|
@ -427,15 +446,28 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
return;
|
||||
}
|
||||
if (item.url === "#documents") {
|
||||
setIsDocumentsSidebarOpen((prev) => {
|
||||
if (!prev) {
|
||||
if (!isMobile) {
|
||||
if (!isDocumentsSidebarOpen) {
|
||||
setIsDocumentsSidebarOpen(true);
|
||||
setIsRightPanelCollapsed(false);
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
} else {
|
||||
setIsRightPanelCollapsed((prev) => !prev);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
} else {
|
||||
setIsDocumentsSidebarOpen((prev) => {
|
||||
if (!prev) {
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (item.url === "#announcements") {
|
||||
|
|
@ -444,7 +476,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setIsInboxSidebarOpen(false);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
|
|
@ -452,7 +483,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router, setIsDocumentsSidebarOpen]
|
||||
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
|
|
@ -549,17 +580,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setIsAllSharedChatsSidebarOpen(true);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
}, []);
|
||||
|
||||
const handleViewAllPrivateChats = useCallback(() => {
|
||||
setIsAllPrivateChatsSidebarOpen(true);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
}, []);
|
||||
|
||||
// Delete handlers
|
||||
const confirmDeleteChat = useCallback(async () => {
|
||||
|
|
@ -688,8 +717,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
markAsRead: statusInbox.markAsRead,
|
||||
markAllAsRead: statusInbox.markAllAsRead,
|
||||
},
|
||||
isDocked: isInboxDocked,
|
||||
onDockedChange: setIsInboxDocked,
|
||||
}}
|
||||
announcementsPanel={{
|
||||
open: isAnnouncementsSidebarOpen,
|
||||
|
|
@ -708,6 +735,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
documentsPanel={{
|
||||
open: isDocumentsSidebarOpen,
|
||||
onOpenChange: setIsDocumentsSidebarOpen,
|
||||
isDocked: isDocumentsDocked,
|
||||
onDockedChange: setIsDocumentsDocked,
|
||||
}}
|
||||
>
|
||||
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { PanelRight } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface HeaderProps {
|
||||
|
|
@ -15,6 +22,7 @@ interface HeaderProps {
|
|||
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||
const pathname = usePathname();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
|
||||
|
|
@ -38,6 +46,13 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const hasRightPanelContent = documentsOpen || reportOpen;
|
||||
const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
||||
{/* Left side - Mobile menu trigger + Model selector */}
|
||||
|
|
@ -49,10 +64,26 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
{showExpandButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="h-8 w-8 shrink-0"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
<span className="sr-only">Expand panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Expand panel</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
168
surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
Normal file
168
surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { PanelRight, PanelRightClose } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { startTransition, useEffect } from "react";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { ReportPanelContent } from "@/components/report-panel/report-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { DocumentsSidebar } from "../sidebar";
|
||||
|
||||
interface RightPanelProps {
|
||||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function CollapseButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onClick} className="h-8 w-8 shrink-0">
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
<span className="sr-only">Collapse panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Collapse panel</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolutely positioned expand button — renders at top-right of the main
|
||||
* container so it occupies the same screen position as the collapse button
|
||||
* inside the Documents header.
|
||||
*/
|
||||
export function RightPanelExpandButton() {
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const hasContent = documentsOpen || reportOpen;
|
||||
|
||||
if (!collapsed || !hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startTransition(() => setCollapsed(false))}
|
||||
className="h-8 w-8 shrink-0"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
<span className="sr-only">Expand panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Expand panel</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PANEL_WIDTHS = { sources: 420, report: 640 } as const;
|
||||
|
||||
export function RightPanel({ documentsPanel }: RightPanelProps) {
|
||||
const [activeTab] = useAtom(rightPanelTabAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const closeReport = useSetAtom(closeReportPanelAtom);
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeReport();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [reportOpen, closeReport]);
|
||||
|
||||
const isVisible = (documentsOpen || reportOpen) && !collapsed;
|
||||
|
||||
const effectiveTab =
|
||||
activeTab === "report" && !reportOpen
|
||||
? "sources"
|
||||
: activeTab === "sources" && !documentsOpen
|
||||
? "report"
|
||||
: activeTab;
|
||||
|
||||
const targetWidth = PANEL_WIDTHS[effectiveTab];
|
||||
const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />;
|
||||
|
||||
const contentKey =
|
||||
effectiveTab === "sources" && documentsOpen
|
||||
? "sources"
|
||||
: effectiveTab === "report" && reportOpen
|
||||
? "report"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.aside
|
||||
key="right-panel"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: targetWidth, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{
|
||||
width: { type: "spring", stiffness: 400, damping: 35, mass: 0.8 },
|
||||
opacity: { duration: 0.2, ease: "easeOut" },
|
||||
}}
|
||||
style={{ willChange: "width, opacity", contain: "layout style" }}
|
||||
className="flex h-full shrink-0 flex-col border-l bg-background overflow-hidden"
|
||||
>
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{contentKey === "sources" && documentsPanel && (
|
||||
<motion.div
|
||||
key="sources"
|
||||
initial={{ opacity: 0, x: 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="h-full"
|
||||
>
|
||||
<DocumentsSidebar
|
||||
open={documentsPanel.open}
|
||||
onOpenChange={documentsPanel.onOpenChange}
|
||||
embedded
|
||||
headerAction={collapseButton}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{contentKey === "report" && (
|
||||
<motion.div
|
||||
key="report"
|
||||
initial={{ opacity: 0, x: 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<ReportPanelContent
|
||||
reportId={reportState.reportId!}
|
||||
title={reportState.title || "Report"}
|
||||
onClose={closeReport}
|
||||
shareToken={reportState.shareToken}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
|
|
@ -10,6 +11,7 @@ import { useSidebarResize } from "../../hooks/useSidebarResize";
|
|||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { Header } from "../header";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import { RightPanel } from "../right-panel/RightPanel";
|
||||
import {
|
||||
AllPrivateChatsSidebar,
|
||||
AllSharedChatsSidebar,
|
||||
|
|
@ -40,8 +42,6 @@ interface InboxProps {
|
|||
totalUnreadCount: number;
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
isDocked?: boolean;
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
}
|
||||
|
||||
interface LayoutShellProps {
|
||||
|
|
@ -97,6 +97,8 @@ interface LayoutShellProps {
|
|||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isDocked?: boolean;
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -306,45 +308,36 @@ export function LayoutShell({
|
|||
isResizing={isResizing}
|
||||
/>
|
||||
|
||||
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
|
||||
{inbox?.isDocked && (
|
||||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
isDocked={inbox.isDocked}
|
||||
onDockedChange={inbox.onDockedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<motion.main
|
||||
layout="position"
|
||||
style={{ contain: "inline-size" }}
|
||||
className="flex-1 flex flex-col min-w-0"
|
||||
>
|
||||
<Header />
|
||||
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</motion.main>
|
||||
|
||||
{/* Floating Inbox Sidebar - positioned absolutely on top of content */}
|
||||
{inbox && !inbox.isDocked && (
|
||||
{/* Right panel — tabbed Sources/Report (desktop only) */}
|
||||
{documentsPanel && (
|
||||
<RightPanel
|
||||
documentsPanel={{
|
||||
open: documentsPanel.open,
|
||||
onOpenChange: documentsPanel.onOpenChange,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inbox Sidebar - slide-out panel */}
|
||||
{inbox && (
|
||||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
isDocked={false}
|
||||
onDockedChange={inbox.onDockedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Documents Sidebar - slide-out panel */}
|
||||
{documentsPanel && (
|
||||
<DocumentsSidebar
|
||||
open={documentsPanel.open}
|
||||
onOpenChange={documentsPanel.onOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArchiveIcon,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
PenLine,
|
||||
RotateCcwIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { ArchiveIcon, MoreHorizontal, PenLine, RotateCcwIcon, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -64,21 +57,26 @@ export function ChatListItem({
|
|||
{...(isMobile ? longPressHandlers : {})}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||
"[&>span:last-child]:truncate",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="w-[calc(100%-3rem)] ">{animatedName}</span>
|
||||
<span className="truncate">{animatedName}</span>
|
||||
</button>
|
||||
|
||||
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
|
||||
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100"
|
||||
"absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
|
||||
isActive
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0 pointer-events-none"
|
||||
: isActive
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { ChevronLeft, ChevronRight, Unplug } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -12,8 +12,12 @@ import {
|
|||
type SortKey,
|
||||
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
||||
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
|
|
@ -21,17 +25,43 @@ import { useDocuments } from "@/hooks/use-documents";
|
|||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
const SHOWCASE_CONNECTORS = [
|
||||
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
||||
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
||||
{ type: "NOTION_CONNECTOR", label: "Notion" },
|
||||
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
|
||||
{ type: "GOOGLE_CALENDAR_CONNECTOR", label: "Google Calendar" },
|
||||
{ type: "SLACK_CONNECTOR", label: "Slack" },
|
||||
{ type: "LINEAR_CONNECTOR", label: "Linear" },
|
||||
{ type: "JIRA_CONNECTOR", label: "Jira" },
|
||||
{ type: "GITHUB_CONNECTOR", label: "GitHub" },
|
||||
] as const;
|
||||
|
||||
interface DocumentsSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isDocked?: boolean;
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
/** When true, renders content without any wrapper — parent provides the container */
|
||||
embedded?: boolean;
|
||||
/** Optional action element rendered in the header row (e.g. collapse button) */
|
||||
headerAction?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) {
|
||||
export function DocumentsSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
isDocked = false,
|
||||
onDockedChange,
|
||||
embedded = false,
|
||||
headerAction,
|
||||
}: DocumentsSidebarProps) {
|
||||
const t = useTranslations("documents");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const params = useParams();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
|
|
@ -148,8 +178,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
|||
|
||||
const documentsContent = (
|
||||
<>
|
||||
<div className="shrink-0 p-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="shrink-0 flex h-14 items-center px-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<Button
|
||||
|
|
@ -162,11 +192,71 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
|||
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isMobile && onDockedChange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => {
|
||||
if (isDocked) {
|
||||
onDockedChange(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
onDockedChange(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDocked ? (
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{headerAction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected tools strip */}
|
||||
<div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectorDialogOpen(true)}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 text-left"
|
||||
>
|
||||
<Unplug className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs text-muted-foreground">Connect your tools</span>
|
||||
<AvatarGroup className="ml-auto shrink-0">
|
||||
{SHOWCASE_CONNECTORS.map(({ type, label }, i) => (
|
||||
<Tooltip key={type}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar className="size-6" style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}>
|
||||
<AvatarFallback className="bg-muted text-[10px]">
|
||||
{getConnectorIcon(type, "size-3.5")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
|
||||
<div className="px-4 pb-2">
|
||||
<DocumentsFilters
|
||||
|
|
@ -199,12 +289,31 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
|||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
{documentsContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDocked && open && !isMobile) {
|
||||
return (
|
||||
<aside
|
||||
className="h-full w-[380px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
||||
aria-label={t("title") || "Documents"}
|
||||
>
|
||||
{documentsContent}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSlideOutPanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
ariaLabel={t("title") || "Documents"}
|
||||
width={isMobile ? undefined : 480}
|
||||
width={isMobile ? undefined : 380}
|
||||
>
|
||||
{documentsContent}
|
||||
</SidebarSlideOutPanel>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
CheckCheck,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
|
|
@ -23,7 +22,7 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -148,8 +147,6 @@ interface InboxSidebarProps {
|
|||
status: TabDataSource;
|
||||
totalUnreadCount: number;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
isDocked?: boolean;
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
}
|
||||
|
||||
export function InboxSidebar({
|
||||
|
|
@ -159,8 +156,6 @@ export function InboxSidebar({
|
|||
status,
|
||||
totalUnreadCount,
|
||||
onCloseMobileSidebar,
|
||||
isDocked = false,
|
||||
onDockedChange,
|
||||
}: InboxSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
|
|
@ -168,7 +163,6 @@ export function InboxSidebar({
|
|||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||
|
||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -822,37 +816,6 @@ export function InboxSidebar({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isMobile && onDockedChange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => {
|
||||
if (isDocked) {
|
||||
setCommentsCollapsed(false);
|
||||
onDockedChange(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setCommentsCollapsed(true);
|
||||
onDockedChange(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDocked ? (
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1095,17 +1058,6 @@ export function InboxSidebar({
|
|||
</>
|
||||
);
|
||||
|
||||
if (isDocked && open && !isMobile) {
|
||||
return (
|
||||
<aside
|
||||
className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
||||
aria-label={t("inbox") || "Inbox"}
|
||||
>
|
||||
{inboxContent}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
||||
{inboxContent}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarContextSafe } from "../../hooks";
|
||||
|
||||
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
|
||||
|
||||
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
||||
|
||||
interface SidebarSlideOutPanelProps {
|
||||
|
|
@ -36,20 +39,29 @@ export function SidebarSlideOutPanel({
|
|||
? SIDEBAR_COLLAPSED_WIDTH
|
||||
: (sidebarContext?.sidebarWidth ?? 240);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Click-away layer - covers the full container including the sidebar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 z-[5]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Backdrop overlay with blur — desktop only, covers main content area (right of sidebar) */}
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ left: sidebarWidth }}
|
||||
className="absolute inset-y-0 right-0 z-20 bg-black/30 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Clip container - positioned at sidebar edge with overflow hidden */}
|
||||
<div
|
||||
|
|
@ -57,7 +69,7 @@ export function SidebarSlideOutPanel({
|
|||
left: isMobile ? 0 : sidebarWidth,
|
||||
width: isMobile ? "100%" : width,
|
||||
}}
|
||||
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
|
||||
className={cn("absolute z-30 overflow-hidden pointer-events-none", "inset-y-0")}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
|
|
|
|||
|
|
@ -95,9 +95,9 @@ function ReportPanelSkeleton() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Inner content component used by both desktop panel and mobile drawer
|
||||
* Inner content component used by desktop panel, mobile drawer, and the layout right panel
|
||||
*/
|
||||
function ReportPanelContent({
|
||||
export function ReportPanelContent({
|
||||
reportId,
|
||||
title,
|
||||
onClose,
|
||||
|
|
@ -294,32 +294,11 @@ function ReportPanelContent({
|
|||
}
|
||||
}, [activeReportId, currentMarkdown]);
|
||||
|
||||
// Show full-page skeleton only on initial load (no data loaded yet).
|
||||
// Once we have versions/content from a prior fetch, keep the action bar visible.
|
||||
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
|
||||
|
||||
if (isLoading && !hasLoadedBefore) {
|
||||
return (
|
||||
<>
|
||||
{/* Minimal top bar with close button even during initial load */}
|
||||
<div className="flex items-center justify-end px-4 py-2 shrink-0">
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close report panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ReportPanelSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action bar — always visible after initial load */}
|
||||
{/* Action bar — always visible; buttons are disabled while loading */}
|
||||
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copy button */}
|
||||
|
|
@ -352,27 +331,51 @@ function ReportPanelContent({
|
|||
>
|
||||
{!shareToken && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Documents</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => handleExport("pdf")} disabled={exporting !== null}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Documents
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
PDF (.pdf)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport("docx")} disabled={exporting !== null}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("docx")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
Word (.docx)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport("odt")} disabled={exporting !== null}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("odt")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
OpenDocument (.odt)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Web & E-Book</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => handleExport("html")} disabled={exporting !== null}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Web & E-Book
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("html")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
HTML (.html)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport("epub")} disabled={exporting !== null}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("epub")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
EPUB (.epub)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Source & Plain</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => handleExport("latex")} disabled={exporting !== null}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Source & Plain
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("latex")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
LaTeX (.tex)
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
|
@ -381,7 +384,10 @@ function ReportPanelContent({
|
|||
Markdown (.md)
|
||||
</DropdownMenuItem>
|
||||
{!shareToken && (
|
||||
<DropdownMenuItem onClick={() => handleExport("plain")} disabled={exporting !== null}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("plain")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
Plain Text (.txt)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
@ -538,9 +544,6 @@ function MobileReportDrawer() {
|
|||
*
|
||||
* On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread)
|
||||
* On mobile/tablet: Renders as a Vaul bottom drawer
|
||||
*
|
||||
* When open on desktop, the comments gutter is automatically suppressed
|
||||
* (handled via showCommentsGutterAtom in current-thread.atom.ts)
|
||||
*/
|
||||
export function ReportPanel() {
|
||||
const panelState = useAtomValue(reportPanelAtom);
|
||||
|
|
@ -555,3 +558,18 @@ export function ReportPanel() {
|
|||
|
||||
return <MobileReportDrawer />;
|
||||
}
|
||||
|
||||
/**
|
||||
* MobileReportPanel — mobile-only report drawer
|
||||
*
|
||||
* Used in the dashboard chat page where the desktop report is handled
|
||||
* by the layout-level RightPanel instead.
|
||||
*/
|
||||
export function MobileReportPanel() {
|
||||
const panelState = useAtomValue(reportPanelAtom);
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
if (isDesktop || !panelState.isOpen || !panelState.reportId) return null;
|
||||
|
||||
return <MobileReportDrawer />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
|
||||
{/* Global info */}
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<Alert className="flex flex-row items-center gap-2 bg-muted/50 py-3 [&>svg]:static [&>svg+div]:translate-y-0 [&>svg~*]:pl-0">
|
||||
<Alert className="bg-muted/50 py-3">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
<span className="font-medium">
|
||||
|
|
|
|||
|
|
@ -700,7 +700,12 @@ function PermissionsEditor({
|
|||
tabIndex={0}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleCategoryExpanded(category); } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleCategoryExpanded(category);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
|
@ -763,7 +768,12 @@ function PermissionsEditor({
|
|||
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
|
||||
)}
|
||||
onClick={() => onTogglePermission(perm.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onTogglePermission(perm.value); } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onTogglePermission(perm.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<span className="text-sm font-medium">{actionLabel}</span>
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ export function DocumentUploadTab({
|
|||
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
|
||||
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
|
||||
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
||||
{t("file_size_limit")}{" "}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -18,31 +19,42 @@ const alertVariants = cva(
|
|||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
);
|
||||
}
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
|
|
|||
|
|
@ -38,4 +38,21 @@ function AvatarFallback({
|
|||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="avatar-group" className={cn("flex -space-x-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted text-xs font-medium text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount };
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
|
|||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
|
||||
className={cn("bg-border mx-2 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
|
||||
|
||||
|
|
@ -58,51 +58,29 @@ function HeroCarouselCard({
|
|||
title,
|
||||
description,
|
||||
src,
|
||||
isActive,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
src: string;
|
||||
isActive: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}) {
|
||||
const { expanded, open, close } = useExpandedGif();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onExpandedChange?.(expanded);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const captureFrame = useCallback((video: HTMLVideoElement) => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
||||
setFrozenFrame(canvas.toDataURL("image/jpeg", 0.85));
|
||||
} catch {
|
||||
/* tainted canvas */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (isActive) {
|
||||
if (video) {
|
||||
setHasLoaded(false);
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
if (video) {
|
||||
if (video.readyState >= 2) captureFrame(video);
|
||||
video.pause();
|
||||
}
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
}, [isActive, captureFrame]);
|
||||
}, [src]);
|
||||
|
||||
const handleCanPlay = useCallback(() => {
|
||||
setHasLoaded(true);
|
||||
|
|
@ -119,40 +97,22 @@ function HeroCarouselCard({
|
|||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 ${
|
||||
isActive ? "cursor-pointer" : "pointer-events-none"
|
||||
}`}
|
||||
onClick={isActive ? open : undefined}
|
||||
>
|
||||
{isActive ? (
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onCanPlay={handleCanPlay}
|
||||
className="w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
{!hasLoaded && frozenFrame && (
|
||||
<img
|
||||
src={frozenFrame}
|
||||
alt={title}
|
||||
className="absolute inset-0 w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
)}
|
||||
{!hasLoaded && !frozenFrame && (
|
||||
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
) : frozenFrame ? (
|
||||
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
|
||||
) : (
|
||||
<div className="aspect-video w-full rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
<div className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950" onClick={open}>
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onCanPlay={handleCanPlay}
|
||||
className="w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
{!hasLoaded && (
|
||||
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,15 +123,42 @@ function HeroCarouselCard({
|
|||
);
|
||||
}
|
||||
|
||||
function usePrefetchVideos() {
|
||||
const videosRef = useRef<HTMLVideoElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function prefetch() {
|
||||
for (const item of carouselItems) {
|
||||
if (cancelled) break;
|
||||
await new Promise<void>((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "auto";
|
||||
video.src = item.src;
|
||||
video.oncanplaythrough = () => resolve();
|
||||
video.onerror = () => resolve();
|
||||
setTimeout(resolve, 10000);
|
||||
videosRef.current.push(video);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prefetch();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
videosRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function HeroCarousel() {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isGifExpanded, setIsGifExpanded] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [cardHeight, setCardHeight] = useState(420);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeCardRef = useRef<HTMLDivElement>(null);
|
||||
const directionRef = useRef<"forward" | "backward">("forward");
|
||||
|
||||
usePrefetchVideos();
|
||||
|
||||
const goTo = useCallback(
|
||||
(newIndex: number) => {
|
||||
directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
|
||||
|
|
@ -188,120 +175,28 @@ function HeroCarousel() {
|
|||
goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1);
|
||||
}, [activeIndex, goTo]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setContainerWidth(el.offsetWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = activeCardRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setCardHeight(el.offsetHeight);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [activeIndex, containerWidth]);
|
||||
|
||||
const cardWidth =
|
||||
containerWidth < 640
|
||||
? containerWidth * 0.85
|
||||
: containerWidth < 1024
|
||||
? Math.min(containerWidth * 0.7, 680)
|
||||
: Math.min(containerWidth * 0.55, 900);
|
||||
|
||||
const baseOffset =
|
||||
containerWidth < 640
|
||||
? containerWidth * 0.2
|
||||
: containerWidth < 1024
|
||||
? containerWidth * 0.15
|
||||
: 150;
|
||||
|
||||
const stackGap = containerWidth < 640 ? 35 : containerWidth < 1024 ? 45 : 55;
|
||||
const perspective = containerWidth < 640 ? 800 : containerWidth < 1024 ? 1000 : 1200;
|
||||
|
||||
const getCardStyle = useCallback(
|
||||
(index: number) => {
|
||||
const diff = index - activeIndex;
|
||||
|
||||
if (diff === 0) {
|
||||
const originX = directionRef.current === "forward" ? 1 : 0;
|
||||
return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX, overlayOpacity: 0, blur: 0 };
|
||||
}
|
||||
|
||||
const dist = Math.abs(diff);
|
||||
const isLeft = diff < 0;
|
||||
const offset = baseOffset + (dist - 1) * stackGap;
|
||||
const t = Math.min(1, dist / 3);
|
||||
|
||||
return {
|
||||
x: -cardWidth / 2 + (isLeft ? -offset : offset),
|
||||
rotateY: isLeft ? 90 : -90,
|
||||
zIndex: 20 - dist,
|
||||
originX: isLeft ? 0 : 1,
|
||||
overlayOpacity: t,
|
||||
blur: t * 6,
|
||||
};
|
||||
},
|
||||
[activeIndex, cardWidth, baseOffset, stackGap]
|
||||
);
|
||||
const item = carouselItems[activeIndex];
|
||||
const isForward = directionRef.current === "forward";
|
||||
|
||||
return (
|
||||
<div className="w-full py-4 sm:py-8">
|
||||
<div ref={containerRef} className="relative mx-auto w-full">
|
||||
<div
|
||||
className="relative z-6 transition-[height] duration-700"
|
||||
style={{ perspective: `${perspective}px`, height: cardHeight }}
|
||||
>
|
||||
{containerWidth > 0 &&
|
||||
carouselItems.map((item, i) => {
|
||||
const style = getCardStyle(i);
|
||||
return (
|
||||
<motion.div
|
||||
key={`carousel_${i}`}
|
||||
ref={i === activeIndex ? activeCardRef : undefined}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: "50%",
|
||||
width: cardWidth,
|
||||
transformStyle: "preserve-3d",
|
||||
zIndex: style.zIndex,
|
||||
transformOrigin: `${style.originX * 100}% 50%`,
|
||||
cursor: i !== activeIndex ? "pointer" : undefined,
|
||||
}}
|
||||
onClick={i !== activeIndex && !isGifExpanded ? () => goTo(i) : undefined}
|
||||
animate={{
|
||||
x: style.x,
|
||||
rotateY: style.rotateY,
|
||||
}}
|
||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ filter: `blur(${style.blur}px)` }}
|
||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
<HeroCarouselCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
src={item.src}
|
||||
isActive={i === activeIndex}
|
||||
onExpandedChange={setIsGifExpanded}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl bg-black sm:rounded-3xl"
|
||||
animate={{ opacity: style.overlayOpacity }}
|
||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative mx-auto w-full max-w-[900px]">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={activeIndex}
|
||||
initial={{ opacity: 0, x: isForward ? 60 : -60 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isForward ? -60 : 60 }}
|
||||
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
<HeroCarouselCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
src={item.src}
|
||||
onExpandedChange={setIsGifExpanded}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="relative z-5 mt-6 flex items-center justify-center gap-4">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
position="top-right"
|
||||
richColors
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
|
|
|
|||
132
surfsense_web/content/docs/code-of-conduct.mdx
Normal file
132
surfsense_web/content/docs/code-of-conduct.mdx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
---
|
||||
title: Code of Conduct
|
||||
description: Community guidelines and expectations for behavior
|
||||
icon: Heart
|
||||
---
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
rohan@surfsense.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
|
|
@ -88,7 +88,7 @@ After saving, you'll find your OAuth credentials on the integration page:
|
|||
|
||||
## Running SurfSense with Airtable Connector
|
||||
|
||||
Add the Airtable credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Airtable credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
AIRTABLE_CLIENT_ID=your_airtable_client_id
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ After creating the app, you'll see your credentials:
|
|||
|
||||
## Running SurfSense with ClickUp Connector
|
||||
|
||||
Add the ClickUp credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the ClickUp credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
CLICKUP_CLIENT_ID=your_clickup_client_id
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ Select the **"Granular scopes"** tab and enable:
|
|||
|
||||
## Running SurfSense with Confluence Connector
|
||||
|
||||
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
ATLASSIAN_CLIENT_ID=your_atlassian_client_id
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ You'll also see your **Application ID** and **Public Key** on this page.
|
|||
|
||||
## Running SurfSense with Discord Connector
|
||||
|
||||
Add the Discord credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Discord credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
DISCORD_CLIENT_ID=your_discord_client_id
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
|
|||
|
||||
## Running SurfSense with Gmail Connector
|
||||
|
||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
|
|||
|
||||
## Running SurfSense with Google Calendar Connector
|
||||
|
||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
|
|||
|
||||
## Running SurfSense with Google Drive Connector
|
||||
|
||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
|
|||
|
||||
## Running SurfSense with Jira Connector
|
||||
|
||||
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
ATLASSIAN_CLIENT_ID=your_atlassian_client_id
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ After creating the application, you'll see your OAuth credentials:
|
|||
|
||||
## Running SurfSense with Linear Connector
|
||||
|
||||
Add the Linear credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Linear credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
LINEAR_CLIENT_ID=your_linear_client_id
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
|
|||
|
||||
## Running SurfSense with Microsoft Teams Connector
|
||||
|
||||
Add the Microsoft Teams credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Microsoft Teams credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
TEAMS_CLIENT_ID=your_microsoft_client_id
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ For additional information:
|
|||
|
||||
## Running SurfSense with Notion Connector
|
||||
|
||||
Add the Notion credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Notion credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
NOTION_OAUTH_CLIENT_ID=your_notion_client_id
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ Click **"Add an OAuth Scope"** to add each scope.
|
|||
|
||||
## Running SurfSense with Slack Connector
|
||||
|
||||
Add the Slack credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
||||
Add the Slack credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||
|
||||
```bash
|
||||
SLACK_CLIENT_ID=your_slack_client_id
|
||||
|
|
|
|||
|
|
@ -1,301 +0,0 @@
|
|||
---
|
||||
title: Docker Installation
|
||||
description: Setting up SurfSense using Docker
|
||||
icon: Container
|
||||
---
|
||||
|
||||
This guide explains how to run SurfSense using Docker, with options ranging from a single-command install to a fully manual setup.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1 — Install Script (recommended)
|
||||
|
||||
Downloads the compose files, generates a `SECRET_KEY`, starts all services, and sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) for automatic daily updates.
|
||||
|
||||
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
|
||||
|
||||
#### For Linux/macOS users:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
#### For Windows users (PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
This creates a `./surfsense/` directory with `docker-compose.yml` and `.env`, then runs `docker compose up -d`.
|
||||
|
||||
To skip Watchtower (e.g. in production where you manage updates yourself):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --no-watchtower
|
||||
```
|
||||
|
||||
To customise the check interval (default 24h), use `--watchtower-interval=SECONDS`.
|
||||
|
||||
### Option 2 — Manual Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MODSetter/SurfSense.git
|
||||
cd SurfSense/docker
|
||||
cp .env.example .env
|
||||
# Edit .env — at minimum set SECRET_KEY
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
After starting, access SurfSense at:
|
||||
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend API**: [http://localhost:8000](http://localhost:8000)
|
||||
- **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs)
|
||||
- **Electric SQL**: [http://localhost:5133](http://localhost:5133)
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
**Option 1 — Watchtower daemon (recommended, auto-updates every 24 h):**
|
||||
|
||||
If you used the install script (Option 1 above), Watchtower is already running. No extra setup needed.
|
||||
|
||||
For manual Docker Compose installs (Option 2), start Watchtower separately:
|
||||
|
||||
```bash
|
||||
docker run -d --name watchtower \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
nickfedor/watchtower \
|
||||
--label-enable \
|
||||
--interval 86400
|
||||
```
|
||||
|
||||
**Option 2 — Watchtower one-time update:**
|
||||
|
||||
```bash
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
nickfedor/watchtower --run-once \
|
||||
--label-filter "com.docker.compose.project=surfsense"
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
Use `nickfedor/watchtower`. The original `containrrr/watchtower` is no longer maintained and may fail with newer Docker versions.
|
||||
</Callout>
|
||||
|
||||
**Option 3 — Manual:**
|
||||
|
||||
```bash
|
||||
cd surfsense # or SurfSense/docker if you cloned manually
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Database migrations are applied automatically on every startup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration lives in a single `docker/.env` file (or `surfsense/.env` if you used the install script). Copy `.env.example` to `.env` and edit the values you need.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SECRET_KEY` | JWT secret key. Generate with: `openssl rand -base64 32`. Auto-generated by the install script. |
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SURFSENSE_VERSION` | Image tag to deploy. Use `latest`, a clean version (e.g. `0.0.14`), or a specific build (e.g. `0.0.14.1`) | `latest` |
|
||||
| `AUTH_TYPE` | Authentication method: `LOCAL` (email/password) or `GOOGLE` (OAuth) | `LOCAL` |
|
||||
| `ETL_SERVICE` | Document parsing: `DOCLING` (local), `UNSTRUCTURED`, or `LLAMACLOUD` | `DOCLING` |
|
||||
| `EMBEDDING_MODEL` | Embedding model for vector search | `sentence-transformers/all-MiniLM-L6-v2` |
|
||||
| `TTS_SERVICE` | Text-to-speech provider for podcasts | `local/kokoro` |
|
||||
| `STT_SERVICE` | Speech-to-text provider for audio files | `local/base` |
|
||||
| `REGISTRATION_ENABLED` | Allow new user registrations | `TRUE` |
|
||||
|
||||
### Ports
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `FRONTEND_PORT` | Frontend service port | `3000` |
|
||||
| `BACKEND_PORT` | Backend API service port | `8000` |
|
||||
| `ELECTRIC_PORT` | Electric SQL service port | `5133` |
|
||||
|
||||
### Custom Domain / Reverse Proxy
|
||||
|
||||
Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, Nginx, Cloudflare Tunnel, etc.). Leave commented out for standard localhost deployments.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) |
|
||||
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) |
|
||||
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) |
|
||||
| `NEXT_PUBLIC_ELECTRIC_URL` | Electric SQL URL used by the frontend (e.g. `https://electric.yourdomain.com`) |
|
||||
|
||||
### Database
|
||||
|
||||
Defaults work out of the box. Change for security in production.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DB_USER` | PostgreSQL username | `surfsense` |
|
||||
| `DB_PASSWORD` | PostgreSQL password | `surfsense` |
|
||||
| `DB_NAME` | PostgreSQL database name | `surfsense` |
|
||||
| `DB_HOST` | PostgreSQL host | `db` |
|
||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||
| `DB_SSLMODE` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `disable` |
|
||||
| `DATABASE_URL` | Full connection URL override. Use for managed databases (RDS, Supabase, etc.) | *(built from above)* |
|
||||
|
||||
### Electric SQL
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `ELECTRIC_DB_USER` | Replication user for Electric SQL | `electric` |
|
||||
| `ELECTRIC_DB_PASSWORD` | Replication password for Electric SQL | `electric_password` |
|
||||
| `ELECTRIC_DATABASE_URL` | Full connection URL override for Electric. Set to `host.docker.internal` when pointing at a local Postgres instance | *(built from above)* |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID (required if `AUTH_TYPE=GOOGLE`) |
|
||||
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret (required if `AUTH_TYPE=GOOGLE`) |
|
||||
|
||||
Create credentials at the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
|
||||
|
||||
### External API Keys
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FIRECRAWL_API_KEY` | Firecrawl API key for web crawling |
|
||||
| `UNSTRUCTURED_API_KEY` | Unstructured.io API key (required if `ETL_SERVICE=UNSTRUCTURED`) |
|
||||
| `LLAMA_CLOUD_API_KEY` | LlamaCloud API key (required if `ETL_SERVICE=LLAMACLOUD`) |
|
||||
|
||||
### Connector OAuth Keys
|
||||
|
||||
Uncomment the connectors you want to use. Redirect URIs follow the pattern `http://localhost:8000/api/v1/auth/<connector>/connector/callback`.
|
||||
|
||||
| Connector | Variables |
|
||||
|-----------|-----------|
|
||||
| Google Drive / Gmail / Calendar | `GOOGLE_DRIVE_REDIRECT_URI`, `GOOGLE_GMAIL_REDIRECT_URI`, `GOOGLE_CALENDAR_REDIRECT_URI` |
|
||||
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` |
|
||||
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_REDIRECT_URI` |
|
||||
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `DISCORD_REDIRECT_URI` |
|
||||
| Jira & Confluence | `ATLASSIAN_CLIENT_ID`, `ATLASSIAN_CLIENT_SECRET`, `JIRA_REDIRECT_URI`, `CONFLUENCE_REDIRECT_URI` |
|
||||
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_REDIRECT_URI` |
|
||||
| ClickUp | `CLICKUP_CLIENT_ID`, `CLICKUP_CLIENT_SECRET`, `CLICKUP_REDIRECT_URI` |
|
||||
| Airtable | `AIRTABLE_CLIENT_ID`, `AIRTABLE_CLIENT_SECRET`, `AIRTABLE_REDIRECT_URI` |
|
||||
| Microsoft Teams | `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_REDIRECT_URI` |
|
||||
|
||||
For Airtable, create an OAuth integration at the [Airtable Developer Hub](https://airtable.com/create/oauth).
|
||||
|
||||
### Observability (optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `LANGSMITH_TRACING` | Enable LangSmith tracing (`true` / `false`) |
|
||||
| `LANGSMITH_ENDPOINT` | LangSmith API endpoint |
|
||||
| `LANGSMITH_API_KEY` | LangSmith API key |
|
||||
| `LANGSMITH_PROJECT` | LangSmith project name |
|
||||
|
||||
### Advanced (optional)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SCHEDULE_CHECKER_INTERVAL` | How often to check for scheduled connector tasks (e.g. `5m`, `1h`) | `5m` |
|
||||
| `RERANKERS_ENABLED` | Enable document reranking for improved search | `FALSE` |
|
||||
| `RERANKERS_MODEL_NAME` | Reranker model name (e.g. `ms-marco-MiniLM-L-12-v2`) | |
|
||||
| `RERANKERS_MODEL_TYPE` | Reranker model type (e.g. `flashrank`) | |
|
||||
| `PAGES_LIMIT` | Max pages per user for ETL services | unlimited |
|
||||
|
||||
---
|
||||
|
||||
## Docker Services
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| `db` | PostgreSQL with pgvector extension |
|
||||
| `redis` | Message broker for Celery |
|
||||
| `backend` | FastAPI application server |
|
||||
| `celery_worker` | Background task processing (document indexing, etc.) |
|
||||
| `celery_beat` | Periodic task scheduler (connector sync) |
|
||||
| `electric` | Electric SQL — real-time sync for the frontend |
|
||||
| `frontend` | Next.js web application |
|
||||
|
||||
All services start automatically with `docker compose up -d`.
|
||||
|
||||
The backend includes a health check — dependent services (workers, frontend) wait until the API is fully ready before starting. You can monitor startup progress with `docker compose ps` (look for `(health: starting)` → `(healthy)`).
|
||||
|
||||
---
|
||||
|
||||
## Development Compose File
|
||||
|
||||
If you're contributing to SurfSense and want to build from source, use `docker-compose.dev.yml` instead:
|
||||
|
||||
```bash
|
||||
cd SurfSense/docker
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
This file builds the backend and frontend from your local source code (instead of pulling prebuilt images) and includes pgAdmin for database inspection at [http://localhost:5050](http://localhost:5050). Use the production `docker-compose.yml` for all other cases.
|
||||
|
||||
The following `.env` variables are **only used by the dev compose file** (they have no effect on the production `docker-compose.yml`):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PGADMIN_PORT` | pgAdmin web UI port | `5050` |
|
||||
| `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` |
|
||||
| `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` |
|
||||
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
|
||||
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` |
|
||||
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` |
|
||||
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` |
|
||||
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend build arg for Electric auth | `insecure` |
|
||||
|
||||
In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from the All-in-One Container
|
||||
|
||||
<Callout type="warn">
|
||||
If you were previously using `docker-compose.quickstart.yml` (the legacy all-in-one `surfsense` container), your data lives in a `surfsense-data` volume and requires a **one-time migration** before switching to the current setup. PostgreSQL has been upgraded from version 14 to 17, so a simple volume swap will not work.
|
||||
|
||||
See the full step-by-step guide: [Migrate from the All-in-One Container](/docs/how-to/migrate-from-allinone).
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# View logs (all services)
|
||||
docker compose logs -f
|
||||
|
||||
# View logs for a specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f electric
|
||||
|
||||
# Stop all services
|
||||
docker compose down
|
||||
|
||||
# Restart a specific service
|
||||
docker compose restart backend
|
||||
|
||||
# Stop and remove all containers + volumes (destructive!)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Ports already in use** — Change the relevant `*_PORT` variable in `.env` and restart.
|
||||
- **Permission errors on Linux** — You may need to prefix `docker` commands with `sudo`.
|
||||
- **Electric SQL not connecting** — Check `docker compose logs electric`. If it shows `domain does not exist: db`, ensure `ELECTRIC_DATABASE_URL` is not set to a stale value in `.env`.
|
||||
- **Real-time updates not working in browser** — Open DevTools → Console and look for `[Electric]` errors. Check that `NEXT_PUBLIC_ELECTRIC_URL` matches the running Electric SQL address.
|
||||
- **Line ending issues on Windows** — Run `git config --global core.autocrlf true` before cloning.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
title: Docker Compose Development
|
||||
description: Building SurfSense from source using docker-compose.dev.yml
|
||||
---
|
||||
|
||||
If you're contributing to SurfSense and want to build from source, use `docker-compose.dev.yml` instead:
|
||||
|
||||
```bash
|
||||
cd SurfSense/docker
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
This file builds the backend and frontend from your local source code (instead of pulling prebuilt images) and includes pgAdmin for database inspection at [http://localhost:5050](http://localhost:5050). Use the production `docker-compose.yml` for all other cases.
|
||||
|
||||
## Dev-Only Environment Variables
|
||||
|
||||
The following `.env` variables are **only used by the dev compose file** (they have no effect on the production `docker-compose.yml`):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PGADMIN_PORT` | pgAdmin web UI port | `5050` |
|
||||
| `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` |
|
||||
| `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` |
|
||||
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
|
||||
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` |
|
||||
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` |
|
||||
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` |
|
||||
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend build arg for Electric auth | `insecure` |
|
||||
|
||||
In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source.
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
---
|
||||
title: Docker Compose
|
||||
description: Manual Docker Compose setup for SurfSense
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MODSetter/SurfSense.git
|
||||
cd SurfSense/docker
|
||||
cp .env.example .env
|
||||
# Edit .env, at minimum set SECRET_KEY
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
After starting, access SurfSense at:
|
||||
|
||||
- **Frontend**: [http://localhost:3929](http://localhost:3929)
|
||||
- **Backend API**: [http://localhost:8929](http://localhost:8929)
|
||||
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
|
||||
- **Electric SQL**: [http://localhost:5929](http://localhost:5929)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration lives in a single `docker/.env` file (or `surfsense/.env` if you used the install script). Copy `.env.example` to `.env` and edit the values you need.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SECRET_KEY` | JWT secret key. Generate with: `openssl rand -base64 32`. Auto-generated by the install script. |
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SURFSENSE_VERSION` | Image tag to deploy. Use `latest`, a clean version (e.g. `0.0.14`), or a specific build (e.g. `0.0.14.1`) | `latest` |
|
||||
| `AUTH_TYPE` | Authentication method: `LOCAL` (email/password) or `GOOGLE` (OAuth) | `LOCAL` |
|
||||
| `ETL_SERVICE` | Document parsing: `DOCLING` (local), `UNSTRUCTURED`, or `LLAMACLOUD` | `DOCLING` |
|
||||
| `EMBEDDING_MODEL` | Embedding model for vector search | `sentence-transformers/all-MiniLM-L6-v2` |
|
||||
| `TTS_SERVICE` | Text-to-speech provider for podcasts | `local/kokoro` |
|
||||
| `STT_SERVICE` | Speech-to-text provider for audio files | `local/base` |
|
||||
| `REGISTRATION_ENABLED` | Allow new user registrations | `TRUE` |
|
||||
|
||||
### Ports
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `FRONTEND_PORT` | Frontend service port | `3929` |
|
||||
| `BACKEND_PORT` | Backend API service port | `8929` |
|
||||
| `ELECTRIC_PORT` | Electric SQL service port | `5929` |
|
||||
|
||||
### Custom Domain / Reverse Proxy
|
||||
|
||||
Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, Nginx, Cloudflare Tunnel, etc.). Leave commented out for standard localhost deployments.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) |
|
||||
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) |
|
||||
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) |
|
||||
| `NEXT_PUBLIC_ELECTRIC_URL` | Electric SQL URL used by the frontend (e.g. `https://electric.yourdomain.com`) |
|
||||
|
||||
### Database
|
||||
|
||||
Defaults work out of the box. Change for security in production.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DB_USER` | PostgreSQL username | `surfsense` |
|
||||
| `DB_PASSWORD` | PostgreSQL password | `surfsense` |
|
||||
| `DB_NAME` | PostgreSQL database name | `surfsense` |
|
||||
| `DB_HOST` | PostgreSQL host | `db` |
|
||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||
| `DB_SSLMODE` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `disable` |
|
||||
| `DATABASE_URL` | Full connection URL override. Use for managed databases (RDS, Supabase, etc.) | *(built from above)* |
|
||||
|
||||
### Electric SQL
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `ELECTRIC_DB_USER` | Replication user for Electric SQL | `electric` |
|
||||
| `ELECTRIC_DB_PASSWORD` | Replication password for Electric SQL | `electric_password` |
|
||||
| `ELECTRIC_DATABASE_URL` | Full connection URL override for Electric. Set to `host.docker.internal` when pointing at a local Postgres instance | *(built from above)* |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID (required if `AUTH_TYPE=GOOGLE`) |
|
||||
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret (required if `AUTH_TYPE=GOOGLE`) |
|
||||
|
||||
Create credentials at the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
|
||||
|
||||
### External API Keys
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FIRECRAWL_API_KEY` | [Firecrawl](https://www.firecrawl.dev/) API key for web crawling |
|
||||
| `UNSTRUCTURED_API_KEY` | [Unstructured.io](https://unstructured.io/) API key (required if `ETL_SERVICE=UNSTRUCTURED`) |
|
||||
| `LLAMA_CLOUD_API_KEY` | [LlamaCloud](https://cloud.llamaindex.ai/) API key (required if `ETL_SERVICE=LLAMACLOUD`) |
|
||||
|
||||
### Connector OAuth Keys
|
||||
|
||||
Uncomment the connectors you want to use. Redirect URIs follow the pattern `http://localhost:8000/api/v1/auth/<connector>/connector/callback`.
|
||||
|
||||
| Connector | Variables |
|
||||
|-----------|-----------|
|
||||
| Google Drive / Gmail / Calendar | `GOOGLE_DRIVE_REDIRECT_URI`, `GOOGLE_GMAIL_REDIRECT_URI`, `GOOGLE_CALENDAR_REDIRECT_URI` |
|
||||
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` |
|
||||
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_REDIRECT_URI` |
|
||||
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `DISCORD_REDIRECT_URI` |
|
||||
| Jira & Confluence | `ATLASSIAN_CLIENT_ID`, `ATLASSIAN_CLIENT_SECRET`, `JIRA_REDIRECT_URI`, `CONFLUENCE_REDIRECT_URI` |
|
||||
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_REDIRECT_URI` |
|
||||
| ClickUp | `CLICKUP_CLIENT_ID`, `CLICKUP_CLIENT_SECRET`, `CLICKUP_REDIRECT_URI` |
|
||||
| Airtable | `AIRTABLE_CLIENT_ID`, `AIRTABLE_CLIENT_SECRET`, `AIRTABLE_REDIRECT_URI` |
|
||||
| Microsoft Teams | `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_REDIRECT_URI` |
|
||||
|
||||
### Observability (optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `LANGSMITH_TRACING` | Enable LangSmith tracing (`true` / `false`) |
|
||||
| `LANGSMITH_ENDPOINT` | LangSmith API endpoint |
|
||||
| `LANGSMITH_API_KEY` | LangSmith API key |
|
||||
| `LANGSMITH_PROJECT` | LangSmith project name |
|
||||
|
||||
### Advanced (optional)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SCHEDULE_CHECKER_INTERVAL` | How often to check for scheduled connector tasks (e.g. `5m`, `1h`) | `5m` |
|
||||
| `RERANKERS_ENABLED` | Enable document reranking for improved search | `FALSE` |
|
||||
| `RERANKERS_MODEL_NAME` | Reranker model name (e.g. `ms-marco-MiniLM-L-12-v2`) | |
|
||||
| `RERANKERS_MODEL_TYPE` | Reranker model type (e.g. `flashrank`) | |
|
||||
| `PAGES_LIMIT` | Max pages per user for ETL services | unlimited |
|
||||
|
||||
---
|
||||
|
||||
## Docker Services
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| `db` | PostgreSQL with pgvector extension |
|
||||
| `redis` | Message broker for Celery |
|
||||
| `backend` | FastAPI application server |
|
||||
| `celery_worker` | Background task processing (document indexing, etc.) |
|
||||
| `celery_beat` | Periodic task scheduler (connector sync) |
|
||||
| `electric` | Electric SQL (real-time sync for the frontend) |
|
||||
| `frontend` | Next.js web application |
|
||||
|
||||
All services start automatically with `docker compose up -d`.
|
||||
|
||||
The backend includes a health check. Dependent services (workers, frontend) wait until the API is fully ready before starting. You can monitor startup progress with `docker compose ps` (look for `(health: starting)` → `(healthy)`).
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# View logs (all services)
|
||||
docker compose logs -f
|
||||
|
||||
# View logs for a specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f electric
|
||||
|
||||
# Stop all services
|
||||
docker compose down
|
||||
|
||||
# Restart a specific service
|
||||
docker compose restart backend
|
||||
|
||||
# Stop and remove all containers + volumes (destructive!)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Ports already in use**: Change the relevant `*_PORT` variable in `.env` and restart.
|
||||
- **Permission errors on Linux**: You may need to prefix `docker` commands with `sudo`.
|
||||
- **Electric SQL not connecting**: Check `docker compose logs electric`. If it shows `domain does not exist: db`, ensure `ELECTRIC_DATABASE_URL` is not set to a stale value in `.env`.
|
||||
- **Real-time updates not working in browser**: Open DevTools → Console and look for `[Electric]` errors. Check that `NEXT_PUBLIC_ELECTRIC_URL` matches the running Electric SQL address.
|
||||
- **Line ending issues on Windows**: Run `git config --global core.autocrlf true` before cloning.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: One-Line Install Script
|
||||
description: One-command installation of SurfSense using Docker
|
||||
---
|
||||
|
||||
Downloads the compose files, generates a `SECRET_KEY`, starts all services, and sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) for automatic daily updates.
|
||||
|
||||
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
|
||||
|
||||
### For Linux/macOS users:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### For Windows users (PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
This creates a `./surfsense/` directory with `docker-compose.yml` and `.env`, then runs `docker compose up -d`.
|
||||
|
||||
To skip Watchtower (e.g. in production where you manage updates yourself):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --no-watchtower
|
||||
```
|
||||
|
||||
To customise the check interval (default 24h), use `--watchtower-interval=SECONDS`.
|
||||
|
||||
---
|
||||
|
||||
## Access SurfSense
|
||||
|
||||
After starting, access SurfSense at:
|
||||
|
||||
- **Frontend**: [http://localhost:3929](http://localhost:3929)
|
||||
- **Backend API**: [http://localhost:8929](http://localhost:8929)
|
||||
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
|
||||
- **Electric SQL**: [http://localhost:5929](http://localhost:5929)
|
||||
6
surfsense_web/content/docs/docker-installation/meta.json
Normal file
6
surfsense_web/content/docs/docker-installation/meta.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"title": "Docker Installation",
|
||||
"pages": ["install-script", "docker-compose", "updating", "dev-compose", "migrate-from-allinone"],
|
||||
"icon": "Container",
|
||||
"defaultOpen": false
|
||||
}
|
||||
|
|
@ -81,87 +81,6 @@ bash migrate-database.sh --db-user myuser --db-password mypass --db-name mydb
|
|||
|
||||
---
|
||||
|
||||
## Option C — Manual steps
|
||||
|
||||
For users who prefer full control or whose platform doesn't support bash scripts (e.g. Windows without WSL2).
|
||||
|
||||
### Step 1 — Stop the old all-in-one container
|
||||
|
||||
Before mounting the `surfsense-data` volume into a new container, stop the existing one to prevent two PostgreSQL processes from writing to the same data directory:
|
||||
|
||||
```bash
|
||||
docker stop surfsense 2>/dev/null || true
|
||||
```
|
||||
|
||||
### Step 2 — Start a temporary PostgreSQL 14 container
|
||||
|
||||
```bash
|
||||
docker run -d --name surfsense-pg14-temp \
|
||||
-v surfsense-data:/data \
|
||||
-e PGDATA=/data/postgres \
|
||||
-e POSTGRES_USER=surfsense \
|
||||
-e POSTGRES_PASSWORD=surfsense \
|
||||
-e POSTGRES_DB=surfsense \
|
||||
pgvector/pgvector:pg14
|
||||
```
|
||||
|
||||
Wait ~10 seconds, then confirm it is healthy:
|
||||
|
||||
```bash
|
||||
docker exec surfsense-pg14-temp pg_isready -U surfsense
|
||||
```
|
||||
|
||||
### Step 3 — Dump the database
|
||||
|
||||
```bash
|
||||
docker exec -e PGPASSWORD=surfsense surfsense-pg14-temp \
|
||||
pg_dump -U surfsense surfsense > surfsense_backup.sql
|
||||
```
|
||||
|
||||
### Step 4 — Recover your SECRET\_KEY
|
||||
|
||||
```bash
|
||||
docker run --rm -v surfsense-data:/data alpine cat /data/.secret_key
|
||||
```
|
||||
|
||||
### Step 5 — Set up the new stack
|
||||
|
||||
```bash
|
||||
mkdir -p surfsense/scripts
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/docker-compose.yml -o surfsense/docker-compose.yml
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/.env.example -o surfsense/.env.example
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/postgresql.conf -o surfsense/postgresql.conf
|
||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/init-electric-user.sh -o surfsense/scripts/init-electric-user.sh
|
||||
chmod +x surfsense/scripts/init-electric-user.sh
|
||||
cp surfsense/.env.example surfsense/.env
|
||||
```
|
||||
|
||||
Set `SECRET_KEY` in `surfsense/.env` to the value from Step 4.
|
||||
|
||||
### Step 6 — Start PostgreSQL 17 and restore
|
||||
|
||||
```bash
|
||||
cd surfsense
|
||||
docker compose up -d db
|
||||
docker compose exec db pg_isready -U surfsense # wait until ready
|
||||
docker compose exec -T db psql -U surfsense -d surfsense < ../surfsense_backup.sql
|
||||
```
|
||||
|
||||
### Step 7 — Start all services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Step 8 — Clean up
|
||||
|
||||
```bash
|
||||
docker stop surfsense-pg14-temp && docker rm surfsense-pg14-temp
|
||||
docker volume rm surfsense-data # only after verifying migration succeeded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `install.sh` runs normally with a blank database (no migration happened)
|
||||
50
surfsense_web/content/docs/docker-installation/updating.mdx
Normal file
50
surfsense_web/content/docs/docker-installation/updating.mdx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
title: Updating
|
||||
description: How to update your SurfSense Docker deployment
|
||||
---
|
||||
|
||||
## Watchtower Daemon (recommended)
|
||||
|
||||
Auto-updates every 24 hours. If you used the [install script](/docs/docker-installation/install-script), Watchtower is already running. No extra setup needed.
|
||||
|
||||
For [manual Docker Compose](/docs/docker-installation/docker-compose) installs, start Watchtower separately:
|
||||
|
||||
```bash
|
||||
docker run -d --name watchtower \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
nickfedor/watchtower \
|
||||
--label-enable \
|
||||
--interval 86400
|
||||
```
|
||||
|
||||
## Watchtower One-Time Update
|
||||
|
||||
```bash
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
nickfedor/watchtower --run-once \
|
||||
--label-filter "com.docker.compose.project=surfsense"
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
Use `nickfedor/watchtower`. The original `containrrr/watchtower` is no longer maintained and may fail with newer Docker versions.
|
||||
</Callout>
|
||||
|
||||
## Manual Update
|
||||
|
||||
```bash
|
||||
cd surfsense # or SurfSense/docker if you cloned manually
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Database migrations are applied automatically on every startup.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from the All-in-One Container
|
||||
|
||||
<Callout type="warn">
|
||||
If you were previously using `docker-compose.quickstart.yml` (the legacy all-in-one `surfsense` container), your data lives in a `surfsense-data` volume and requires a **one-time migration** before switching to the current setup. PostgreSQL has been upgraded from version 14 to 17, so a simple volume swap will not work.
|
||||
|
||||
See the full step-by-step guide: [Migrate from the All-in-One Container](/docs/docker-installation/migrate-from-allinone).
|
||||
</Callout>
|
||||
|
|
@ -5,7 +5,7 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
|
|||
|
||||
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, 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?
|
||||
## What does Electric SQL do?
|
||||
|
||||
When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
||||
|
||||
|
|
@ -23,45 +23,24 @@ This means:
|
|||
|
||||
## Docker Setup
|
||||
|
||||
The `docker-compose.yml` includes the Electric SQL service. It is pre-configured to connect to the Docker-managed `db` container out of the box.
|
||||
- The `docker-compose.yml` includes the Electric SQL service, pre-configured to connect to the Docker-managed `db` container.
|
||||
- No additional configuration is required. Electric SQL works with the Docker PostgreSQL instance out of the box.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
## Manual Setup (Development Only)
|
||||
|
||||
The Electric SQL service configuration in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
electric:
|
||||
image: electricsql/electric:1.4.6
|
||||
ports:
|
||||
- "${ELECTRIC_PORT:-5133}:3000"
|
||||
environment:
|
||||
DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
ELECTRIC_INSECURE: "true"
|
||||
ELECTRIC_WRITE_TO_PG_MODE: direct
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
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.
|
||||
This section is intended for local development environments. 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 in `docker/.env`:
|
||||
For Electric SQL, verify these variables are set:
|
||||
|
||||
**Backend (`surfsense_backend/.env`):**
|
||||
|
||||
```bash
|
||||
ELECTRIC_PORT=5133
|
||||
ELECTRIC_DB_USER=electric
|
||||
ELECTRIC_DB_PASSWORD=electric_password
|
||||
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
|
||||
```
|
||||
|
||||
**Frontend (`surfsense_web/.env`):**
|
||||
|
|
@ -71,17 +50,19 @@ NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
|
|||
NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
|
||||
```
|
||||
|
||||
Next, choose the option that matches your PostgreSQL setup:
|
||||
|
||||
---
|
||||
|
||||
### Option A: Using Docker PostgreSQL
|
||||
|
||||
If you're using the Docker-managed PostgreSQL instance, no extra configuration is needed. Just start the services:
|
||||
If you're using the Docker-managed PostgreSQL instance, no extra configuration is needed. Just start the services using the development compose file (which exposes the PostgreSQL port to your host machine):
|
||||
|
||||
```bash
|
||||
docker compose up -d db electric
|
||||
docker compose -f docker-compose.dev.yml up -d db electric
|
||||
```
|
||||
|
||||
Then run the database migration and start the backend:
|
||||
Then run the database migration, start the backend, and launch the frontend:
|
||||
|
||||
```bash
|
||||
cd surfsense_backend
|
||||
|
|
@ -89,6 +70,13 @@ uv run alembic upgrade head
|
|||
uv run main.py
|
||||
```
|
||||
|
||||
In a separate terminal, start the frontend:
|
||||
|
||||
```bash
|
||||
cd surfsense_web
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Electric SQL is now configured and connected to your Docker PostgreSQL database.
|
||||
|
||||
---
|
||||
|
|
@ -148,7 +136,7 @@ ELECTRIC_DATABASE_URL=postgresql://electric:electric_password@host.docker.intern
|
|||
**4. Start Electric SQL only (skip the Docker `db` container):**
|
||||
|
||||
```bash
|
||||
docker compose up -d --no-deps electric
|
||||
docker compose -f docker-compose.dev.yml up -d --no-deps electric
|
||||
```
|
||||
|
||||
The `--no-deps` flag starts only the `electric` service without starting the Docker-managed `db` container.
|
||||
|
|
@ -161,18 +149,32 @@ uv run alembic upgrade head
|
|||
uv run main.py
|
||||
```
|
||||
|
||||
In a separate terminal, start the frontend:
|
||||
|
||||
```bash
|
||||
cd surfsense_web
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Electric SQL is now configured and connected to your local PostgreSQL database.
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
**Required for manual setup:**
|
||||
|
||||
| Variable | Location | Description | Default |
|
||||
|----------|----------|-------------|---------|
|
||||
| `ELECTRIC_PORT` | `docker/.env` | Port to expose Electric SQL | `5133` |
|
||||
| `ELECTRIC_DB_USER` | `docker/.env` | Database user for Electric replication | `electric` |
|
||||
| `ELECTRIC_DB_PASSWORD` | `docker/.env` | Database password for Electric replication | `electric_password` |
|
||||
| `ELECTRIC_DATABASE_URL` | `docker/.env` | Full connection URL override for Electric. Set to use `host.docker.internal` when pointing at a local Postgres instance | *(built from above defaults)* |
|
||||
| `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` |
|
||||
| `ELECTRIC_DB_USER` | `surfsense_backend/.env` | Database user for Electric replication | `electric` |
|
||||
| `ELECTRIC_DB_PASSWORD` | `surfsense_backend/.env` | Database password for Electric replication | `electric_password` |
|
||||
| `NEXT_PUBLIC_ELECTRIC_URL` | `surfsense_web/.env` | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` |
|
||||
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | `surfsense_web/.env` | Authentication mode (`insecure` for dev, `secure` for production) | `insecure` |
|
||||
|
||||
**Optional / Docker-only:**
|
||||
|
||||
| Variable | Location | Description | Default |
|
||||
|----------|----------|-------------|---------|
|
||||
| `ELECTRIC_PORT` | `docker/.env` | Port to expose Electric SQL on the host | `5133` (dev), `5929` (production) |
|
||||
| `ELECTRIC_DATABASE_URL` | `docker/.env` | Full connection URL override for Electric. Only needed for Option B (local Postgres via `host.docker.internal`) | *(built from above defaults)* |
|
||||
|
||||
## Verify Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"title": "How to",
|
||||
"pages": ["electric-sql", "realtime-collaboration", "migrate-from-allinone"],
|
||||
"icon": "BookOpen",
|
||||
"pages": ["electric-sql", "realtime-collaboration"],
|
||||
"icon": "Compass",
|
||||
"defaultOpen": false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,61 @@
|
|||
---
|
||||
title: Prerequisites
|
||||
description: Required setup's before setting up SurfSense
|
||||
icon: ClipboardCheck
|
||||
title: Documentation
|
||||
description: Welcome to SurfSense's documentation
|
||||
icon: BookOpen
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card';
|
||||
import { ClipboardCheck, Download, Container, Wrench, Cable, BookOpen, FlaskConical, Heart } from 'lucide-react';
|
||||
|
||||
## Auth Setup
|
||||
Welcome to **SurfSense's Documentation!** Here, you'll find everything you need to get the most out of SurfSense. Dive in to explore how SurfSense can be your AI-powered research companion.
|
||||
|
||||
SurfSense supports both Google OAuth and local email/password authentication. Google OAuth is optional - if you prefer local authentication, you can skip this section.
|
||||
|
||||
**Note**: Google OAuth setup is **required** in your `.env` files if you want to use the Gmail and Google Calendar connectors in SurfSense.
|
||||
|
||||
To set up Google OAuth:
|
||||
|
||||
1. Login to your [Google Developer Console](https://console.cloud.google.com/)
|
||||
2. Enable the required APIs:
|
||||
- **People API** (required for basic Google OAuth)
|
||||

|
||||
3. Set up OAuth consent screen.
|
||||

|
||||
4. Create OAuth client ID and secret.
|
||||

|
||||
5. It should look like this.
|
||||

|
||||
|
||||
---
|
||||
|
||||
## File Upload's
|
||||
|
||||
SurfSense supports three ETL (Extract, Transform, Load) services for converting files to LLM-friendly formats:
|
||||
|
||||
### Option 1: Unstructured
|
||||
|
||||
Files are converted using [Unstructured](https://github.com/Unstructured-IO/unstructured)
|
||||
|
||||
1. Get an Unstructured.io API key from [Unstructured Platform](https://platform.unstructured.io/)
|
||||
2. You should be able to generate API keys once registered
|
||||

|
||||
|
||||
### Option 2: LlamaIndex (LlamaCloud)
|
||||
|
||||
Files are converted using [LlamaIndex](https://www.llamaindex.ai/) which offers 50+ file format support.
|
||||
|
||||
1. Get a LlamaIndex API key from [LlamaCloud](https://cloud.llamaindex.ai/)
|
||||
2. Sign up for a LlamaCloud account to access their parsing services
|
||||
3. LlamaCloud provides enhanced parsing capabilities for complex documents
|
||||
|
||||
### Option 3: Docling (Recommended for Privacy)
|
||||
|
||||
Files are processed locally using [Docling](https://github.com/DS4SD/docling) - IBM's open-source document parsing library.
|
||||
|
||||
1. **No API key required** - all processing happens locally
|
||||
2. **Privacy-focused** - documents never leave your system
|
||||
3. **Supported formats**: PDF, Office documents (Word, Excel, PowerPoint), images (PNG, JPEG, TIFF, BMP, WebP), HTML, CSV, AsciiDoc
|
||||
4. **Enhanced features**: Advanced table detection, image extraction, and structured document parsing
|
||||
5. **GPU acceleration** support for faster processing (when available)
|
||||
|
||||
**Note**: You only need to set up one of these services.
|
||||
|
||||
---
|
||||
|
||||
## LLM Observability (Optional)
|
||||
|
||||
This is not required for SurfSense to work. But it is always a good idea to monitor LLM interactions. So we do not have those WTH moments.
|
||||
|
||||
1. Get a LangSmith API key from [smith.langchain.com](https://smith.langchain.com/)
|
||||
2. This helps in observing SurfSense Researcher Agent.
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Crawler
|
||||
|
||||
SurfSense have 2 options for saving webpages:
|
||||
- [SurfSense Extension](https://github.com/MODSetter/SurfSense/tree/main/surfsense_browser_extension) (Overall better experience & ability to save private webpages, recommended)
|
||||
- Crawler (If you want to save public webpages)
|
||||
|
||||
**NOTE:** SurfSense currently uses [Firecrawl.py](https://www.firecrawl.dev/) for web crawling. If you plan on using the crawler, you will need to create a Firecrawl account and get an API key.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once you have all prerequisites in place, proceed to the [installation guide](/docs/installation) to set up SurfSense.
|
||||
<Cards>
|
||||
<Card
|
||||
icon={<ClipboardCheck />}
|
||||
title="Prerequisites"
|
||||
description="Required setup before installing SurfSense"
|
||||
href="/docs/prerequisites"
|
||||
/>
|
||||
<Card
|
||||
icon={<Download />}
|
||||
title="Installation"
|
||||
description="Choose your installation method"
|
||||
href="/docs/installation"
|
||||
/>
|
||||
<Card
|
||||
icon={<Container />}
|
||||
title="Docker Installation"
|
||||
description="Deploy SurfSense with Docker Compose"
|
||||
href="/docs/docker-installation"
|
||||
/>
|
||||
<Card
|
||||
icon={<Wrench />}
|
||||
title="Manual Installation"
|
||||
description="Set up SurfSense manually from source"
|
||||
href="/docs/manual-installation"
|
||||
/>
|
||||
<Card
|
||||
icon={<Cable />}
|
||||
title="Connectors"
|
||||
description="Integrate with third-party services"
|
||||
href="/docs/connectors"
|
||||
/>
|
||||
<Card
|
||||
icon={<BookOpen />}
|
||||
title="How-To Guides"
|
||||
description="Step-by-step guides for common tasks"
|
||||
href="/docs/how-to"
|
||||
/>
|
||||
<Card
|
||||
icon={<FlaskConical />}
|
||||
title="Testing"
|
||||
description="Running and writing tests for SurfSense"
|
||||
href="/docs/testing"
|
||||
/>
|
||||
<Card
|
||||
icon={<Heart />}
|
||||
title="Code of Conduct"
|
||||
description="Community guidelines and expectations"
|
||||
href="/docs/code-of-conduct"
|
||||
/>
|
||||
</Cards>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ There are two ways to install SurfSense, but both require the repository to be c
|
|||
|
||||
This method provides a containerized environment with all dependencies pre-configured. Less Customization.
|
||||
|
||||
[Learn more about Docker installation](/docs/docker-installation)
|
||||
[Learn more about Docker installation](/docs/docker-installation/install-script)
|
||||
|
||||
## Manual Installation (Preferred)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@
|
|||
"pages": [
|
||||
"---Guides---",
|
||||
"index",
|
||||
"prerequisites",
|
||||
"installation",
|
||||
"docker-installation",
|
||||
"manual-installation",
|
||||
"docker-installation",
|
||||
"connectors",
|
||||
"how-to",
|
||||
"---Development---",
|
||||
"testing"
|
||||
"---Developers---",
|
||||
"testing",
|
||||
"code-of-conduct"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
86
surfsense_web/content/docs/prerequisites.mdx
Normal file
86
surfsense_web/content/docs/prerequisites.mdx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
title: Prerequisites
|
||||
description: Required setup's before setting up SurfSense
|
||||
icon: ClipboardCheck
|
||||
---
|
||||
|
||||
|
||||
## Auth Setup
|
||||
|
||||
SurfSense supports both Google OAuth and local email/password authentication. Google OAuth is optional - if you prefer local authentication, you can skip this section.
|
||||
|
||||
**Note**: Google OAuth setup is **required** in your `.env` files if you want to use the Gmail and Google Calendar connectors in SurfSense.
|
||||
|
||||
To set up Google OAuth:
|
||||
|
||||
1. Login to your [Google Developer Console](https://console.cloud.google.com/)
|
||||
2. Enable the required APIs:
|
||||
- **People API** (required for basic Google OAuth)
|
||||

|
||||
3. Set up OAuth consent screen.
|
||||

|
||||
4. Create OAuth client ID and secret.
|
||||

|
||||
5. It should look like this.
|
||||

|
||||
|
||||
---
|
||||
|
||||
## File Upload's
|
||||
|
||||
SurfSense supports three ETL (Extract, Transform, Load) services for converting files to LLM-friendly formats:
|
||||
|
||||
### Option 1: Unstructured
|
||||
|
||||
Files are converted using [Unstructured](https://github.com/Unstructured-IO/unstructured)
|
||||
|
||||
1. Get an Unstructured.io API key from [Unstructured Platform](https://platform.unstructured.io/)
|
||||
2. You should be able to generate API keys once registered
|
||||

|
||||
|
||||
### Option 2: LlamaIndex (LlamaCloud)
|
||||
|
||||
Files are converted using [LlamaIndex](https://www.llamaindex.ai/) which offers 50+ file format support.
|
||||
|
||||
1. Get a LlamaIndex API key from [LlamaCloud](https://cloud.llamaindex.ai/)
|
||||
2. Sign up for a LlamaCloud account to access their parsing services
|
||||
3. LlamaCloud provides enhanced parsing capabilities for complex documents
|
||||
|
||||
### Option 3: Docling (Recommended for Privacy)
|
||||
|
||||
Files are processed locally using [Docling](https://github.com/DS4SD/docling) - IBM's open-source document parsing library.
|
||||
|
||||
1. **No API key required** - all processing happens locally
|
||||
2. **Privacy-focused** - documents never leave your system
|
||||
3. **Supported formats**: PDF, Office documents (Word, Excel, PowerPoint), images (PNG, JPEG, TIFF, BMP, WebP), HTML, CSV, AsciiDoc
|
||||
4. **Enhanced features**: Advanced table detection, image extraction, and structured document parsing
|
||||
5. **GPU acceleration** support for faster processing (when available)
|
||||
|
||||
**Note**: You only need to set up one of these services.
|
||||
|
||||
---
|
||||
|
||||
## LLM Observability (Optional)
|
||||
|
||||
This is not required for SurfSense to work. But it is always a good idea to monitor LLM interactions. So we do not have those WTH moments.
|
||||
|
||||
1. Get a LangSmith API key from [smith.langchain.com](https://smith.langchain.com/)
|
||||
2. This helps in observing SurfSense Researcher Agent.
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Crawler
|
||||
|
||||
SurfSense have 2 options for saving webpages:
|
||||
- [SurfSense Extension](https://github.com/MODSetter/SurfSense/tree/main/surfsense_browser_extension) (Overall better experience & ability to save private webpages, recommended)
|
||||
- Crawler (If you want to save public webpages)
|
||||
|
||||
**NOTE:** SurfSense currently uses [Firecrawl.py](https://www.firecrawl.dev/) for web crawling. If you plan on using the crawler, you will need to create a Firecrawl account and get an API key.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once you have all prerequisites in place, proceed to the [installation guide](/docs/installation) to set up SurfSense.
|
||||
|
|
@ -61,7 +61,7 @@ export function toDisplayDoc(item: ApiDocumentInput): DocumentDisplay {
|
|||
}
|
||||
|
||||
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
|
||||
const INITIAL_PAGE_SIZE = 20;
|
||||
const INITIAL_PAGE_SIZE = 50;
|
||||
const SCROLL_PAGE_SIZE = 5;
|
||||
|
||||
function isValidDocument(doc: DocumentElectric): boolean {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function useInbox(
|
|||
searchSpaceId: number | null,
|
||||
category: NotificationCategory,
|
||||
prefetchedUnread?: { total_unread: number; recent_unread: number } | null,
|
||||
prefetchedUnreadReady = true,
|
||||
prefetchedUnreadReady = true
|
||||
) {
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
|
|
|
|||
|
|
@ -156,9 +156,7 @@ class NotificationsApiService {
|
|||
* Get unread counts for all categories in a single request.
|
||||
* Replaces 2 separate getUnreadCount calls (comments + status).
|
||||
*/
|
||||
getBatchUnreadCounts = async (
|
||||
searchSpaceId?: number
|
||||
): Promise<GetBatchUnreadCountResponse> => {
|
||||
getBatchUnreadCounts = async (searchSpaceId?: number): Promise<GetBatchUnreadCountResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchSpaceId !== undefined) {
|
||||
params.append("search_space_id", String(searchSpaceId));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue